opscape

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
output
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
output
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
output
[ ]: 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
output
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

report.sh
#!/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
outputexit 1
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: $?"
output
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

check.sh
#!/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
explain
outputexit 1
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
output
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