Bash & the POSIX Shell Module 4 – Exit status, tests and branching
if, elif, else and case statements
Branch on any command's exit status with if, elif and else, match words against patterns with case, use fall-through safely, and know when a lookup table fits.
What you will learn
- Write if, elif and else blocks around any command's exit status
- Use case for pattern-based branching with alternatives, character classes and fall-through
- Choose between case, if chains and an associative array for a given branching job
Before you start
What you’ll learn · Before you start
What you will learn
- Write if, elif and else blocks around any command's exit status
- Use case for pattern-based branching with alternatives, character classes and fall-through
- Choose between case, if chains and an associative array for a given branching job
Before you start
On this page
if grep -q ERROR app.log; then needs no brackets at all: if runs a command and branches on its exit status, and
[ ] is just one command among many. A case statement: A compound command, case word in pattern) commands ;; esac, that compares one word with glob patterns from the top and runs the commands of the first that matches. goes further and picks a
branch by pattern, so a dispatcher with five branches reads as one table instead of a chain of tests.
You will write if, elif and else around any command, match words against patterns with case, use its
fall-through terminators knowing which shells accept them, and recognise when a lookup table is clearer than either.
if runs a command and branches on its exit status
if list; then …; elif list; then …; else …; fi runs the first list and takes the then branch when it ends with
status 0. Otherwise each elif list is tried in turn, and the else branch runs when none succeeded. The condition
is any command: grep, cd, a function, (( )) or [ ]. The session branches on grep, on an arithmetic test and
on cd, and ends with an if that takes no branch:
from session import run, write
write("app.log", "INFO started\nWARN disk at 85 %\nINFO ready\n")
run("""
if grep -q ERROR app.log; then echo "errors found"; elif grep -q WARN app.log; then echo "warnings only"; else echo "clean"; fi
n=42; if (( n % 2 == 0 )); then echo "$n is even"; else echo "$n is odd"; fi
if cd nowhere 2>/dev/null; then echo "moved"; else echo "cd failed with $?, still here"; fi
if false; then echo "never printed"; fi; echo "no branch ran, status $?"
""") Output
$ if grep -q ERROR app.log; then echo "errors found"; elif grep -q WARN app.log; then echo "warnings only"; else echo "clean"; fi warnings only $ n=42; if (( n % 2 == 0 )); then echo "$n is even"; else echo "$n is odd"; fi 42 is even $ if cd nowhere 2>/dev/null; then echo "moved"; else echo "cd failed with $?, still here"; fi cd failed with 1, still here $ if false; then echo "never printed"; fi; echo "no branch ran, status $?" no branch ran, status 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 if_session.py
The log had a WARN line but no ERROR, so the first grep failed, the elif grep succeeded, and the script printed
warnings only. (( n % 2 == 0 )) tested an arithmetic condition directly. if cd nowhere took the else branch, and
$? inside it was still 1, the status of the failed cd, because nothing ran in between. That is the advantage over
running a command and testing $? later: the status is used where it is produced.
Each branch can hold any number of commands, and an if statement’s own status is that of the branch that ran, or 0
when no branch ran: the last line of the session tested false with no else, and $? was 0 afterwards, although the
condition had failed. Bash ignores indentation; then may go on its own line or after a ;.
Brackets around a command
Wrong: if [ grep -q ERROR app.log ]; then, which hands grep to test as text and fails with an error. Right:
if grep -q ERROR app.log; then. Brackets are needed only for a test, such as if [ -f "$file" ]; then.
Checkpoint
Check: if, elif and else
case matches a word against patterns, from the top
case word in pattern) commands ;; … esac expands the word once and compares it with each pattern in order. The
patterns use glob notation (*, ? and brackets), but they are compared with the word, never with file names. The
first match runs its commands and ;; ends the statement; when no pattern matches, no branch runs and case ends
with status 0, as an if does when no branch ran. | joins alternatives, and *), which matches anything, is the
usual last case. The word is neither split nor expanded into file names, so case $word in needs no quotes, while a
quoted pattern is taken literally: '*') matches only a star. classify.sh sorts each of its arguments into one of
four kinds with one case inside a loop:
#!/bin/sh
# Classifies each argument with case: patterns are tried from the top, | joins alternatives, * is the default.
for word in "$@"; do
case $word in
apple | mango | banana) echo "$word: a fruit" ;;
[[:upper:]]*) echo "$word: starts with a capital" ;;
'' | *[!0-9]*) echo "$word: not a number" ;;
*) echo "$word: a number" ;;
esac
done Step through its four patterns for four of the words the session gives it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- mango. The first pattern lists three alternatives joined by |. mango is one of them, so the first branch runs and case stops.
- Pune, first pattern. A new word starts again at the top. Pune is not apple, mango or banana, so the next pattern is tried.
- Pune, second pattern. [[:upper:]]* matches any word whose first character is a capital letter, whatever follows. Pune matches, so it is "starts with a capital".
- 12a, third pattern. 12a fails the first two patterns. [!0-9] matches a word containing at least one character that is not a digit, and the a is one, so 12a is "not a number".
- The catch-all. Only a word made of digits alone, such as 4096, gets past the third pattern. * matches anything, so it ends as "a number". Without it, such a word would run no branch at all.
The session runs classify.sh on five words in Bash and again in dash, then calls dispatch.sh, the dispatcher
shown further down, three ways:
from session import files, run
files("classify.sh", "dispatch.sh")
run("""
bash classify.sh mango Pune 4096 12a ''
dash classify.sh mango Pune 4096 12a ''
bash dispatch.sh start
!bash dispatch.sh restart
bash dispatch.sh --help
""") Output
$ bash classify.sh mango Pune 4096 12a '' mango: a fruit Pune: starts with a capital 4096: a number 12a: not a number : not a number $ dash classify.sh mango Pune 4096 12a '' mango: a fruit Pune: starts with a capital 4096: a number 12a: not a number : not a number $ bash dispatch.sh start starting the service $ bash dispatch.sh restart usage: dispatch.sh start|stop|status [exit status 2] $ bash dispatch.sh --help usage: dispatch.sh start|stop|status
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 case_session.py
mango matched the alternatives apple | mango | banana. Pune matched [[:upper:]]*, any word that starts with
a capital letter; a character class such as [:upper:] is safer than [A-Z], whose meaning can depend on the
locale. 4096 reached the last case, and 12a and the empty word matched '' | *[!0-9]*: the empty word, or a word
with any character that is not a digit. [!…] negates a bracket expression in POSIX patterns, and dash printed exactly
the same five lines. Order matters: Pune is not a number either, yet it stopped at the second pattern, the first
one that matched.
dispatch.sh is a typical use of case: one branch per subcommand, one for -h and --help, and a last *) branch
for every other word, which prints the usage on standard error and ends the script with status 2:
#!/bin/bash
# A subcommand dispatcher: one case branch per command, a usage message and status 2 for anything else.
usage() { echo "usage: dispatch.sh start|stop|status" >&2; }
case ${1-} in
start) echo "starting the service" ;;
stop) echo "stopping the service" ;;
status) echo "the service is running" ;;
-h | --help) usage ;;
*) usage; exit 2 ;;
esac start ran its own branch. restart is not one of its subcommands, so it reached *), and the session recorded
status 2 for it.
Key idea
case tries its patterns from the top and stops at the first match, so put specific patterns first and end with *) to catch everything else.
Checkpoint
Check: case and its patterns
Fall-through terminators, and when a lookup table is better
Besides ;;, a case clause can end with ;&, which also runs the next clause’s commands without testing its
pattern, or ;;&, which goes on testing the later patterns. Bash added both in version 4.0, as its NEWS file records.
POSIX.1-2024 adopted ;& as a standard terminator, but not ;;&, which stays a Bash extension. The session tries
both, then a lookup:
from session import run
# Bash 4.0 added two terminators: ;& runs the next body as well, and ;;& goes on testing the later patterns.
# POSIX.1-2024 made ;& standard, but dash 0.5.13.5 still refuses it; ;;& is Bash's own. An associative array
# replaces a case whose branches only map keys to values.
run("""
level=warn; case $level in error) echo "page someone" ;& warn) echo "write to the log" ;; *) echo "ignore" ;; esac
level=error; case $level in error) echo "page someone" ;& warn) echo "write to the log" ;; *) echo "ignore" ;; esac
f=report.pdf; case $f in *.pdf) echo "a PDF" ;;& report*) echo "a report" ;;& *) echo "checked every pattern" ;; esac
!dash -c 'case x in x) echo one ;& y) echo two ;; esac'
declare -A port=([http]=80 [https]=443 [ssh]=22)
echo "https is on port ${port[https]}"
""") Output
$ level=warn; case $level in error) echo "page someone" ;& warn) echo "write to the log" ;; *) echo "ignore" ;; esac
write to the log
$ level=error; case $level in error) echo "page someone" ;& warn) echo "write to the log" ;; *) echo "ignore" ;; esac
page someone
write to the log
$ f=report.pdf; case $f in *.pdf) echo "a PDF" ;;& report*) echo "a report" ;;& *) echo "checked every pattern" ;; esac
a PDF
a report
checked every pattern
$ dash -c 'case x in x) echo one ;& y) echo two ;; esac'
dash: 1: Syntax error: "&" unexpected
[exit status 2]
$ declare -A port=([http]=80 [https]=443 [ssh]=22)
$ echo "https is on port ${port[https]}"
https is on port 443
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fallthrough_session.py
With level=error, ;& made the error branch fall into the warn branch, printing page someone and write to the log; with level=warn only the second line appeared. ;;& let report.pdf match three patterns in turn. dash
0.5.13.5 refused ;& with Syntax error: "&" unexpected, so a #!/bin/sh script that must run there should not use
it, standard or not.
Which construct fits depends on what the branches compare. The third option in the table is an associative array: A Bash array whose subscripts are strings rather than numbers, such as capital[India]; declare -A creates one, it needs Bash 4.0, and its keys come back in no defined order. More in “Associative arrays: key-value maps”, which Bash has had since version 4.0 and POSIX sh lacks:
| Branching job | Best tool | Why |
|---|---|---|
| Conditions on different commands or values | if / elif |
each condition is its own command |
| One word against several patterns | case |
patterns read as a table; works in every POSIX shell |
| Fixed keys mapped to values | an associative array | ${port[https]} is a lookup, not a branch |
The last two lines of the session built one, declare -A port=([http]=80 [https]=443 [ssh]=22), and
${port[https]} gave 443 with no branching at all. When every branch of a case only assigns a value, a table says
the same thing in less code and can grow without new branches.
Checkpoint
Check: Fall-through and lookup tables
Practise: if and case
The questions use the log, the classifier and the dispatcher of the sessions with new words and values. Decide which branch runs before you check; each explanation ties the answer back to what the sessions showed:
Practice that adapts to you
Practise this lesson
11 questions about 3 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): when would you use case instead of if and elif in a shell script?
Use case when one word is compared with several patterns: subcommands, file extensions, answers such as y | yes.
Its patterns are globs, alternatives are joined with |, it tries them from the top and stops at the first match,
and *) catches everything else, so a dispatcher reads like a table. It is POSIX, so it works in dash as well. Use
if and elif when each branch tests something different: a file, a number, the status of a command. When every
branch only maps a key to a value, an associative array is clearer than either.
Two harder questions follow, about a dispatcher that silently ignores unknown commands and about a case whose order
sends inputs to the wrong branch. 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.
-
Fresher to mid
A deploy job runs service.sh restat, a typo for restart. The script's case has branches for start, stop and restart only. It printed nothing, the job reported success, and the old version kept running for an hour. Explain what happened and fix the script.
-
Mid to senior
An upload handler sorts files with case: .gz) runs gunzip, .tar.gz) runs tar -xzf, and *) leaves the file alone. Users report that backup.tar.gz ends up as backup.tar, never unpacked. Explain the cause, fix it, and say how you would stop the same mistake coming back.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does if actually test?
The exit status of any command list: if grep -q ERROR app.log; then runs grep and branches on its status. Brackets are needed only when the condition is a test, such as [ -f "$file" ].
-
How do elif and else decide which branch runs?
if runs its first list; when it fails, each elif list is tried in turn, and else runs only when none of them succeeded.
-
In which order does case try its patterns?
From the top, and it runs only the first match, so specific patterns go first and *) at the end catches everything else. When nothing matches, case ends with status 0.
-
Which pattern forms can a case clause use?
Globs, alternatives joined with | such as y | yes, and bracket expressions such as [!0-9] or classes such as [[:upper:]], all of which work in every POSIX shell.
-
What do ;& and ;;& do at the end of a case clause?
;& runs the next clause's commands without testing its pattern, and ;;& goes on testing the patterns that follow. Bash has both since 4.0; POSIX.1-2024 has ;&, but dash 0.5.13.5 refuses it.
-
When is an associative array better than a long case?
When the branches only map a key to a value, such as a service name to its port: one lookup replaces many clauses that each set a variable.
Key takeaways
ifruns any command list and branches on its exit status,eliftries further lists in turn andelseruns when none succeeded; brackets are needed only for a test.casecompares one word with glob patterns from the top and runs the first match, ending with status 0 when none matches;|joins alternatives,*)catches the rest, and[!…]and classes such as[:upper:]work in every POSIX shell.;&falls into the next clause and;;&keeps testing; Bash has both since 4.0 and POSIX.1-2024 has;&, but dash 0.5.13.5 refuses;&.- Use
iffor different conditions,casefor one word against patterns, and an associative array when branches only map keys to values.
Terms in this lesson
- case statement
- A compound command, case word in pattern) commands ;; esac, that compares one word with glob patterns from the top and runs the commands of the first that matches.
References
- Bash Reference Manual: Conditional Constructs (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (2.9.4.3 case) (The Open Group and IEEE)
- POSIX.1-2024: Shell Command Language (2.9.4.4 if) (The Open Group and IEEE)
- Bash NEWS (bash-4.0: the ;& and ;;& case terminators, associative arrays) (Chet Ramey (GNU Bash maintainer))
Report a problem with this lesson
Kept only in this browser. Your Learn progress