Bash scripting guide · part 12 of 32
Conditionals
How if, elif, &&, || and ! decide using exit statuses, the three test syntaxes, guard clauses, and why a && b || c is not an if-else.
In most languages if evaluates a boolean expression. In the shell, if runs a command
and looks at its exit status: 0 means true, anything else means false. Brackets are not part
of the if syntax at all; [ is just a command (with ] as its last argument) that happens to
be used in conditions a lot.
if COMMANDS; then
...
elif COMMANDS; then
...
else
...
fi
Any command is a condition
Programs that answer yes-or-no questions report it in their exit status: grep -q (found a
match?), cmp -s (files identical?), id -u name (user exists?), command -v (program
installed?). Use them directly; there is no need to run the command and then inspect $?.
Use a command's exit status directly
if grep -q "TODO" notes.txt; then
echo "notes.txt has open items"
fi
if ! command -v git >/dev/null; then
echo "git is not installed"
fi
if cmp -s todo.txt todo.txt; then echo "identical"; fi
notes.txt has open items git is not installed identical
grep -q prints nothing and exits 0 on a match. ! inverts a status, so the second block
runs because command -v git failed; git is not installed in this sandbox. cmp -s
compares silently and reports only through its status.
The roundabout form cmd; if [ $? -eq 0 ]; then works but is longer, and breaks as soon as
someone adds a line between the command and the test. Reserve $? for when you need the
specific code, as with grep, which returns 1 for "no match" and 2 for "error".
Distinguish no match from an error
for f in todo.txt notes.txt missing.txt; do
grep -q "TODO" "$f" 2>/dev/null
case $? in
0) echo "$f: has a TODO" ;;
1) echo "$f: no TODO" ;;
*) echo "$f: could not be read" ;;
esac
done
todo.txt: no TODO notes.txt: has a TODO missing.txt: could not be read
Telling the three outcomes apart needs $? and is clearest with case (The case statement). An
if grep would have lumped the missing file in with "no TODO".
Three ways to write a test
| Syntax | What it is | Use for |
|---|---|---|
[ ... ] or test ... |
a builtin command, POSIX | portable sh scripts |
[[ ... ]] |
bash keyword with its own parsing | string and file tests in bash |
(( ... )) |
bash arithmetic command | numeric comparisons |
[[ ]] is safer than [ ]: variables inside are not word-split or globbed, &&, ||, <
and > work as operators rather than shell syntax, and == can match glob patterns, =~
regular expressions. All operators are covered in Test operators.
The same decisions in each syntax
file="file with spaces.txt"; count=12
if [ -f "$file" ] && [ "$count" -gt 10 ]; then echo "[ ]: yes"; fi
if [[ -f $file && $count -gt 10 ]]; then echo "[[ ]]: yes"; fi
if (( count > 10 )); then echo "(( )): yes"; fi
if [[ $file == *.txt ]]; then echo "pattern match: a .txt file"; fi
[ ]: yes [[ ]]: yes (( )): yes pattern match: a .txt file
[ ] needs every variable quoted and its tests chained with the shell's && between two
commands. Inside [[ ]], $file is safe unquoted and && works inside the brackets. (( ))
uses ordinary math symbols.
elif and else
Chains of elif are evaluated top to bottom and the first true branch wins. When every
branch compares the same variable against fixed values or patterns, case is usually cleaner.
Choose a branch by disk usage
for used in 45 82 97; do
if (( used >= 95 )); then
level=critical
elif (( used >= 80 )); then
level=warning
else
level=ok
fi
echo "$used% -> $level"
done
45% -> ok 82% -> warning 97% -> critical
82 fails the first test and passes the second, so it never reaches else. Order the
tests from most to least specific.
&& and || as short conditionals
a && b runs b only if a succeeded; a || b runs b only if a failed. For a single
action they read well, especially as guard clauses at the top of a script that exit early
when a precondition does not hold.
Guard clauses
#!/usr/bin/env bash
die() { echo "report.sh: $*" >&2; exit 1; }
[[ $# -eq 1 ]] || die "usage: report.sh FILE"
[[ -r $1 ]] || die "cannot read $1"
echo "$1 has $(wc -l < "$1") lines"
bash report.sh todo.txt
bash report.sh nope.txt
todo.txt has 5 lines report.sh: cannot read nope.txt
Each check either passes silently or stops the script with a message on stderr and status 1.
The main logic then runs without nesting inside if blocks.
a && b || c is not if-then-else
It is tempting to write test && then-part || else-part. But || reacts to the status of
everything before it, including b. If the "then" command itself fails, the "else" command
runs too.
When the then-part fails, the else-part also runs
[[ -f todo.txt ]] && grep -q "release" todo.txt || echo "todo.txt does not exist"
if [[ -f todo.txt ]]; then grep -q "release" todo.txt; else echo "todo.txt does not exist"; fi
echo "if status: $?"
todo.txt does not exist if status: 1
The file exists, but grep found no "release" and failed, so the || branch printed a
false claim. The real if runs exactly one branch; its status is that of the grep, 1.
Conditions and set -e
With set -e, a failing command normally ends the script. Commands used as conditions are
exempt, because failing is their way of saying "false": the command after if, elif, while
and until, anything negated by !, and every command in an && or || list except the last.
Conditions do not trigger set -e
#!/usr/bin/env bash
set -e
if grep -q "release" todo.txt; then echo "found"; else echo "not found"; fi
grep -q "release" todo.txt && echo "found"
echo "still running"
grep -q "release" todo.txt
echo "never printed"
bash check.sh
not found still running
The failing grep was harmless in the if and on the left of &&. The bare grep on
line 6 failed outside any condition, so set -e stopped the script with status 1.
Testing output, not status
Sometimes the question is "did this print anything?" rather than "did it succeed?". [[ -n
$(cmd) ]] tests for non-empty output. When the output could be large, a command with a
meaningful status, like grep -q or find ... -print -quit, is cheaper.
Branch on whether a command printed anything
if [[ -n $(find logs -name '*.log' -size +500c) ]]; then
echo "some logs are over 500 bytes:"
find logs -name '*.log' -size +500c
fi
if [[ -z $(find logs -name '*.gz') ]]; then echo "no compressed logs yet"; fi
some logs are over 500 bytes: logs/app.log no compressed logs yet
find always exits 0 when it can read the directories, even with no matches, so its
status cannot answer the question; its output can. -n tests for a non-empty string, -z
for an empty one.
Related
testEvaluate a condition about files, strings or integers and return 0 for true or 1 for false.trueDo nothing and exit with status 0 (success).falseDo nothing and exit with status 1 (failure).grepPrint the lines of files (or standard input) that match a pattern.cmpCompare two files byte by byte and report the first difference, or just whether they differ.findWalk a directory tree and print, or act on, every file that matches a set of tests.- Test operatorsEvery file, string and number test for [ ] and [[ ]], pattern and regex matching, and the traps like
[ $a > $b ]creating a file. - The case statementMatching one value against glob patterns with case, alternatives with |, the default branch, fall-through with ;& and ;;&, and parsing options with case.
- Exit codesWhat exit statuses mean, how $? works, the reserved values 126, 127 and 128+N, PIPESTATUS, and how to choose codes for your own scripts.
- Strict mode and its caveatsWhat set -euo pipefail actually does, the cases where -e silently does nothing, the traps in -u and pipefail, and a balanced way to use it.
- ArithmeticInteger math with $(( )) and (( )), the exit status of (( )) that breaks set -e, leading-zero octal surprises, and what to use for decimals.