OnCallReady

Lesson 6.5 · Bash Scripting · 18 min read

Exit status, subshells and case

In plain words

Think of a game of "if you can, then". "If you can open the door, go in; otherwise knock." You do not look at the door and describe it; you just try it, and what happens decides the next step. if in bash works like that: it runs a command and looks at its exit status, 0 meaning "it worked". [ and [[ are just commands that happen to answer questions.

Parentheses ( ) are like sending a copy of yourself into another room: whatever the copy does there (cd, set a variable) stays in that room. Braces { } keep you in the same room. case is a sorting machine that drops a value into the first matching slot, like dev|test) or prod-*). And printf is the reliable way to print data exactly as it is.

Why this lesson

Every decision a script makes - "did the copy work?", "is nginx running?" - comes down to an exit status. Get the mechanics wrong and the script checks the wrong command, or loses a variable it just set. This lesson is the plumbing under if, pipes and $(...), plus case for choosing between several values.

What you need to know already: exit status and $?, && / || (1.7); if, $(cmd) and PIPESTATUS (6.3); systemctl is-active (2.1).

if runs a command

if, while and &&/|| do not test "expressions" - they run a command and branch on its exit status (0 = true). The [[ ]] test you will meet in 6.10 is just another command that happens to compute a status:

if grep -q "^learner:" /etc/passwd; then echo "exists"; fi     # grep IS the test
if systemctl is-active --quiet nginx; then ...
if ! mkdir "$dir"; then echo "cannot create $dir" >&2; exit 1; fi

(^learner: means "a line that starts with learner:" - ^ is "start of line".)

So this common pattern is a detour (shellcheck SC2181):

some_command
if [ $? -ne 0 ]; then ...      # checks $?... unless someone adds a line in between
if ! some_command; then ...    # say what you mean

Many tools have a quiet mode made for this - no output, only the status: grep -q, systemctl is-active --quiet, diff -q (do two files differ?), cmp -s (are two files byte-identical?).

Every stage of a pipeline has a status

$ false | true | false; echo "${PIPESTATUS[@]}"
1 0 1
$ ls /nope | wc -l; echo "last=$? stages=${PIPESTATUS[*]}"
ls: cannot access '/nope': No such file or directory
0
last=0 stages=2 0

The first line: three stages, statuses 1, 0 and 1. The second: ls failed with 2, wc -l (count lines) printed 0 and succeeded, so $? says 0.

$? is the last stage (or, with pipefail, the last failing one). PIPESTATUS is an array with every stage (${PIPESTATUS[@]} = all of them; arrays are in 6.12) - and, like $?, it is overwritten by the next command, so copy it immediately: st=("${PIPESTATUS[@]}").

( ) is a new shell; { } is not

$ ( cd /; pwd ); pwd
/
/home/learner
$ { cd /tmp; pwd; }; pwd
/tmp
/tmp

Parentheses run the commands in a subshell: a copy of the shell, started as a child process (3.1), whose cd, variables and exit stay inside it and vanish when it ends. Braces group commands in the current shell (note the spaces and the final ;). Use ( ) deliberately to contain a cd or a set -x (tracing, 6.22); use { } to redirect a group's output at once.

Subshells are also created, less visibly, by:

An exit inside $(...) exits only the substitution, but its status becomes the status of the assignment:

$ x=$( exit 3 ); echo $?
3

That is exactly why x=$(cmd) works with set -e - and local x=$(cmd) does not (6.3): there, local's own status replaces it.

Command substitution also strips all trailing newlines (printf is below; \n in it means newline):

$ echo "$(printf 'a\n\n\n')|"
a|

case: pattern matching on a value

case compares one value against a list of patterns and runs the branch of the first one that matches - a tidy replacement for a chain of ifs:

case "$env" in
  dev|test)   replicas=1 ;;
  staging)    replicas=2 ;;
  prod-*)     replicas=6 ;;
  "")         echo "env is empty" >&2; exit 2 ;;
  *)          echo "unknown env: $env" >&2; exit 2 ;;
esac

case is the right tool for dispatching on a subcommand (start|stop|status) and for option parsing; long if/elif chains of string comparisons usually want to be a case.

printf, not echo, for data

$ var='-n'
$ echo "$var"               # prints nothing: echo took it as an option

$ printf '%s\n' "$var"
-n
$ printf '%-10s %5d\n' orders 42 payments 7
orders        42
payments       7

printf FORMAT ARGS... prints its arguments according to a format string: %s is "a string goes here", %d "an integer goes here", \n a newline. %-10s pads to 10 characters, left-aligned; %5d pads a number to 5, right-aligned.

echo behaves differently across shells and treats data that looks like an option as an option. printf is the same everywhere, reuses the format for extra arguments (the second example printed two lines), and does column formatting. Use echo for fixed messages, printf for anything containing a variable you did not write yourself.

Exit codes you should produce

0     success
1     generic failure
2     usage error (bad arguments) - see 6.20
3-125 your own, documented

and the ones the shell produces for you: 126 not executable, 127 not found, 128+N killed by signal N. Keep your own below 126 so they can never be confused with those.

What you can now do

Why it helps

Understanding that if runs commands is what lets you write clean checks: if ! systemctl is-active --quiet nginx, if grep -q ..., if mkdir "$dir", instead of fragile $? checks that break when someone adds a log line in between. That is a frequent review comment (SC2181).

Subshell rules explain bugs you will meet in real scripts: variables lost after a pipe into while, a cd inside $(...) that did not move the script, an exit that only left a subshell. case is the standard tool for service and deploy scripts dispatching on start|stop|status or environments. And printf instead of echo avoids data being eaten as options.

Commands in this lesson

true false ls echo printf

FAQ

Is [ a command?

Yes. [ is a bash builtin and also a real binary at /usr/bin/[, identical to test; the closing ] is just its last argument. That is why spaces are required around it and why its arguments go through word splitting and globbing. [[ ]] is different: it is bash syntax parsed by the shell itself, which is why it is safer. Both simply return an exit status for if to use.

Why is if [ $? -ne 0 ] discouraged?

Because it separates the command from the check, and anything inserted between them, a log line or a debug echo, silently changes $?. It is also longer than saying what you mean. if ! cmd; then tests the command directly. When you need the status value itself, capture it immediately: cmd; rc=$?, then compare rc. shellcheck warns with SC2181.

What is the difference between ( ) and { }?

( cmds ) runs the commands in a subshell, a forked copy of the shell; changes to variables, the working directory, options and traps disappear when it ends, and exit leaves only the subshell. { cmds; } groups commands in the current shell, useful for redirecting several commands together; it needs spaces inside and a final ; or newline. Use ( ) to contain a cd or set -x.

Why did command substitution remove my trailing blank lines?

$(...) always strips all trailing newlines from the output. That is usually convenient (x=$(hostname) has no newline), but it changes data where trailing newlines matter, such as a file's exact content. The usual workaround is to append a sentinel character and remove it afterwards: x=$(cmd; printf .); x=${x%.}.

Are case patterns regular expressions?

No, they are glob patterns: * any string, ? one character, [abc] a character class, and | separates alternatives. prod-* matches anything starting with "prod-". For regular expressions use [[ $x =~ regex ]]. With shopt -s extglob, case also supports extended patterns like +(a|b). The first matching branch wins, so put *) last as the default.

In an interview Junior

How does if work in bash? Is it evaluating a boolean expression?

No. if runs a command and branches on its exit status: 0 is true, anything else is false. [[ ]] is just one more command that happens to compute a status. So the command itself can be the test:

if grep -q "^learner:" /etc/passwd; then echo exists; fi
if systemctl is-active --quiet nginx; then ...
if ! mkdir "$dir"; then echo "cannot create $dir" >&2; exit 1; fi

while, && and || work the same way. Tools have quiet modes made for this - grep -q, cmp -s, diff -q - no output, only the status.

Prefer if ! cmd over running cmd and testing $? afterwards (shellcheck SC2181): any line added in between overwrites $?. For several values of one variable, a case is tidier than a chain of ifs.

Also asked: What runs in a subshell in bash, and why does it matter? · Why use printf instead of echo in scripts? · What is the difference between ( ) and { } around commands?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.