Shell Lesson 3 of 42

Conditionals, Exit Codes & Status Propagation: test, [, [[, true/false, $? — How Shell Actually Decides What to Do

There is exactly one way the shell decides whether to take a branch, retry a command, exit early, run the next pipe stage, or trigger set -e: it looks at the exit status of the most recently executed command. Zero means success. Non-zero means failure. Everything else — every if, every while, every &&, every ||, every test in [ ] or [[ ]] — is a thin syntactic wrapper over that single primitive.

Most beginners write shell as if if and while were like the same constructs in Python or C. They are not. They are command-runners that branch on exit codes, and once you internalise that, every weird shell-conditional behaviour you’ve ever encountered makes complete sense.

This lesson covers the exit-code contract, the three test commands and when to use each, the precise difference between && / || chaining and if blocks (and why this difference matters for set -e), and the conditional idioms you’ll write thousands of times.

In a nutshell

Level: Beginner · Time: ~40 min

Imagine every command you run is a relay runner who, the instant they finish, hands the shell a single slip of paper with one number on it. The shell doesn’t watch the race or read the runner’s face — it reads only that slip. If the number is 0, the shell treats it as “done, all good.” If it’s anything else, it treats it as “that failed.” That number is the command’s exit status, and the whole of shell control flow — every if, while, &&, ||, and set -e — is just the shell reacting to slips of paper. It never looks at the text a command printed to decide what to do next; it looks at the status.

This is the single biggest thing that separates people who “get” shell from people who fight it. In Python or JavaScript, if (x) asks “is this value truthy?” In shell, if grep ...; then means “run grep, and if its slip says 0, take the branch.” The thing after if is a command that gets executed, not an expression that gets evaluated. Once that clicks, the three lookalike test tools (test, [, [[) stop being mysterious: they’re just commands whose entire job is to hand back a 0-or-1 slip.

There are only a few moving parts, and this whole lesson is the detail behind them. A command produces a status (0 = success). The special variable $? holds the last status — but only the last, and it’s overwritten by the very next command, so grab it fast if you need it. The deciders (if, while, &&, ||, !) read that status to branch. The guards (set -e, trap ERR) watch for non-zero statuses to abort or report — with a few deliberate blind spots you must know about.

If you keep only three sentences: zero means success and everything else means failure; if COMMAND runs the command and branches on its exit status, so any command can be a condition; and $? holds only the most recent status, so capture it with rc=$? the moment you need it. Everything below is the detail behind those three sentences.

Shell exit-code decision flow read left to right: a command or pipeline produces an integer exit status (0 for success, non-zero as a failure category such as 1, 2, 126, 127, or 128+N); that status is stored in the $? slot and can be captured with rc=$? before it resets; the deciders if, while, until, &&, ||, and ! read the status to branch, invert, or chain; and the guards set -e and trap ERR escalate on a non-zero status, subject to suppression inside conditions, after !, and on the left of && / ||; six numbered badges mark the exit-code contract, the status categories, the $?-resets-every-command trap, the if-runs-a-command idea, the A-&&-B-||-C pitfall, and errexit suppression in conditional contexts

Read the diagram left to right as the life of a single decision: a command runs and produces a status; the status lands in $? (capture it before the next command overwrites it); the deciders (if, while, &&, ||, !) read it to choose a branch; and the guards (set -e, trap ERR) escalate on failure — except in the conditional contexts where errexit is deliberately suppressed. The six badges are the six things beginners get wrong; every section below expands one of them.

Prerequisites & what you’ll be able to do

You should already be comfortable running commands at a prompt, setting and reading shell variables, and — importantly — quoting variables ("$VAR" vs $VAR), because unquoted expansion is the source of half the conditional bugs in this lesson. If quoting and word-splitting are still fuzzy, read Variables, quoting & parameter expansion first. A mental model of what a command actually is — a process the shell forks and waits on, which returns a status when it exits — makes the exit-code contract obvious; that lives in Shell anatomy & the process model.

After working through this lesson you will be able to:


1. The exit code is the only truth

Every command — every binary, every shell built-in, every function, every pipeline — terminates with an integer exit code in the range 0–255. Conventionally:

You can see the exit code of the last command with the special variable $?:

ls /tmp
echo "$?"          # 0 — ls succeeded

ls /nonexistent
echo "$?"          # 2 — ls failed with "no such file or directory"

false
echo "$?"          # 1 — false always exits 1

true
echo "$?"          # 0 — true always exits 0

true and false are real, executable commands (or shell built-ins, depending on the shell) whose only purpose is to provide guaranteed exit codes. They are useful in conditionals, infinite loops, and tests:

while true; do
  echo "Running..."
  sleep 1
done

Note: $? is reset by every command. If you need to use it more than once, capture it immediately:

my-tool
RC=$?
if (( RC != 0 )); then
  echo "Failed with code $RC" >&2
  exit "$RC"
fi

If you wrote if ((... != 0)) first and then tried to use $?, the if itself would have reset $?.

The exit-code map (keep this handy)

Because “the number is a category” trips people up, here is the full conventional map in one place. You will not memorise all of it, but you will recognise these when they appear:

Code Meaning Where it comes from
0 success the command finished cleanly
1 generic/unspecified error most tools’ catch-all failure
2 misuse of a builtin / usage error shell builtins, many CLIs on bad args
126 found but not executable permission denied, or it’s a directory
127 command not found typo, or not on PATH
128 invalid argument to exit e.g. exit 3.5 or exit foo
128+N killed by signal N the process was terminated by a signal
130 128 + 2 → killed by SIGINT you pressed Ctrl+C
137 128 + 9 → killed by SIGKILL OOM-killer, or kill -9
143 128 + 15 → killed by SIGTERM a graceful kill, systemd stop
255 out of range / “don’t know” exit -1 wraps to 255; also SSH’s own errors

Two practical consequences. First, 127 and 126 are the shell telling you it couldn’t run the thing at all — before your program’s own logic ever executed — so treat them as “wrong path / wrong permissions,” not “my program failed.” Second, exit codes are stored in 8 bits: exit 256 becomes 0 (success!) and exit -1 becomes 255. Always exit a value in 0255.

The colon command

There’s a built-in command called : (colon). It does nothing and always returns 0. It’s used as a no-op:

if [ -f /tmp/foo ]; then
  :          # do nothing if the file exists
else
  touch /tmp/foo
fi

You’ll also see it as a portable way to expand variables for their side effects (e.g. with ${VAR:?error}):

: "${REQUIRED_VAR:?REQUIRED_VAR must be set}"

That single line, at the top of a script, is a clean way to fail-fast on missing required variables without polluting output.


2. if is a command-runner, not a boolean expression

This is the central concept. In C or Python, if (expr) evaluates expr to a Boolean and branches. In shell:

if COMMAND; then
  THEN_BRANCH
else
  ELSE_BRANCH
fi

if runs COMMAND, looks at its exit code, and if zero it runs the then branch, otherwise the else. COMMAND is a real, executable command. It can be grep, curl, [, [[, a function call, a pipeline, anything.

if grep -q "ERROR" /var/log/app.log; then
  alert-pager
fi

That works because grep -q exits 0 if it found a match and 1 if it didn’t. The -q flag tells grep to be quiet (no output) and just return the exit code.

if curl -fsS https://api.example.com/health >/dev/null; then
  echo "API is up"
else
  echo "API is DOWN"
  exit 1
fi

That works because curl -f exits non-zero on HTTP error responses (4xx/5xx). The -s is silent (no progress bar), -S shows errors even when silent (so set -e can pick them up). This is the canonical curl-in-script flag set: -fsS for “fail silently but show errors and exit non-zero on HTTP failure.”

The body of if can be a pipeline. The exit status of a pipeline is normally the exit status of the last command (we covered set -o pipefail in lesson 2):

if grep -E '^ERROR' /var/log/app.log | grep -q 'database'; then
  echo "Database errors found"
fi

You can also chain multiple commands with ; or &&:

if cd /app && [ -f config.toml ]; then
  ./run.sh
fi

cd /app && [ -f config.toml ] succeeds only if both cd and the file test succeed. If cd fails, the && short-circuits and the whole condition is the failure code from cd.

The if-elif-else chain

if [[ "$1" == "start" ]]; then
  start_service
elif [[ "$1" == "stop" ]]; then
  stop_service
elif [[ "$1" == "restart" ]]; then
  stop_service
  start_service
else
  echo "Usage: $0 {start|stop|restart}" >&2
  exit 2
fi

Mechanically identical to nested if blocks. For more than two or three branches, prefer case (section 7).


3. The three test commands: test, [, and [[

This is the area that confuses beginners most. There are three commands that look like conditional expressions. They are not the same. They have different syntax, different operators, and very different behaviour around quoting and word splitting.

test EXPR — the original POSIX form

if test -f /etc/hostname; then
  echo "exists"
fi

test is a real command (built into bash, but also a separate binary at /usr/bin/test). It evaluates the expression and exits 0 (true) or 1 (false). It’s POSIX-compliant — works in dash, ash, busybox sh, every shell ever.

[ EXPR ] — exactly the same as test, but uglier and prettier

if [ -f /etc/hostname ]; then
  echo "exists"
fi

The [ is also a real command. It’s a binary at /bin/[ (or a shell built-in) whose name is literally [. It expects its last argument to be ]. The brackets are not syntax — they’re command-name and argument. That’s why you must have spaces around them:

[ -f /etc/hostname ]    # CORRECT — '[' is the command, '-f' '/etc/hostname' ']' are arguments
[-f /etc/hostname ]     # WRONG — bash looks for a command called "[-f"
[ -f /etc/hostname]     # WRONG — the last argument is "/etc/hostname]" not "]"

For all practical purposes, [ EXPR ] and test EXPR are interchangeable. POSIX-compliant. Works everywhere.

The classic gotcha with [:

NAME=""
if [ $NAME = "alice" ]; then     # expands to: if [ = alice ]; — syntax error, "test" gets confused
  echo "hi alice"
fi

When NAME is empty, the unquoted $NAME expands to nothing (zero tokens), and [ = alice ] is a malformed test expression. The fix: always quote variables in [:

if [ "$NAME" = "alice" ]; then   # expands to: if [ "" = alice ]; — works
  echo "hi alice"
fi

This is one of many reasons to prefer [[, which we’ll cover next.

[[ EXPR ]] — bash’s superior form

if [[ -f /etc/hostname ]]; then
  echo "exists"
fi

[[ is a shell keyword, not a command. It’s part of bash’s grammar. This is a fundamental difference: because it’s grammar, bash parses it specially and the rules inside are different.

Inside [[ ]]:

Inside [ ]:

So which one should you use?

Use [[ ]] in bash scripts. Use [ ] (or test) only in POSIX-portable scripts — scripts whose shebang is #!/bin/sh and need to run on dash, ash, or busybox.

In this course, every example uses [[ ]] unless we’re explicitly discussing portability (lesson 31).

Which conditional command? A decision table

Four constructs, one job (produce a 0/1 exit status), different strengths. Pick with this table:

Construct Kind Use it for Portable? Quoting-safe? Notes
test EXPR / [ EXPR ] external-style command / builtin POSIX #!/bin/sh scripts; simple file & string checks ✅ POSIX ❌ must quote every $var &&/`
[[ EXPR ]] bash keyword default for bash string/file logic ❌ bashism ✅ no word-split, no glob on $var supports =~, &&, `
(( EXPR )) bash keyword any numeric comparison or arithmetic ❌ bashism (ksh/zsh too) ✅ (arithmetic context) C-style; returns 1 when the expression is 0
$(( EXPR )) arithmetic expansion computing a number to use as text ✅ POSIX n/a expands to a value; does not set a useful exit status by itself

The trap in row four is worth underlining now and again in section 3-plus: (( )) is a command that returns success (0) when the arithmetic result is non-zero, and failure (1) when it’s zero — the opposite of what a C programmer expects from a bare expression. $(( )) is a different animal entirely: it’s expansion, it produces text, and you use it like total=$(( a + b )). Same double-paren look, completely different roles.

Operator reference

Here are the operators you’ll use most. They work in all three forms unless noted.

File tests (all forms):

Operator Meaning
-e PATH exists (any type)
-f PATH is a regular file
-d PATH is a directory
-L PATH is a symlink
-r PATH readable
-w PATH writable
-x PATH executable
-s PATH exists and is non-empty (size > 0)
-p PATH is a named pipe (FIFO)
-S PATH is a socket
-N PATH modified since last read
A -nt B file A is newer than B
A -ot B file A is older than B
A -ef B same file (same device + inode, follows links)
if [[ -f /etc/passwd ]]; then echo "exists"; fi
if [[ -d /var/log ]]; then echo "is directory"; fi
if [[ -x /usr/bin/jq ]]; then echo "jq available"; fi
if [[ /etc/passwd -nt /etc/passwd.bak ]]; then echo "passwd has changed since backup"; fi

String tests:

Operator Meaning
-z STR string is empty (length zero)
-n STR string is non-empty
STR1 = STR2 strings are equal (POSIX)
STR1 == STR2 strings are equal (bash; in [[ ]], RHS is glob)
STR1 != STR2 strings differ
STR1 < STR2 lexicographically less than (only [[ ]])
STR1 > STR2 lexicographically greater than (only [[ ]])
NAME="alice"
if [[ -z "$NAME" ]]; then echo "name is empty"; fi
if [[ "$NAME" == "alice" ]]; then echo "exact match"; fi
if [[ "$NAME" == a* ]]; then echo "starts with a"; fi    # glob match (no quotes on RHS!)
if [[ "$NAME" == "a*" ]]; then echo "literal a*"; fi     # literal (RHS quoted = glob disabled)

The unquoted right-hand side of == inside [[ ]] is a glob pattern. This is enormously useful — you can do prefix and suffix matching without invoking grep. But beware: if you want a literal match against a string that might contain * or ?, quote the RHS.

Numeric tests:

Operator Meaning
N1 -eq N2 equal
N1 -ne N2 not equal
N1 -lt N2 less than
N1 -le N2 less than or equal
N1 -gt N2 greater than
N1 -ge N2 greater than or equal
COUNT=5
if [[ "$COUNT" -gt 3 ]]; then echo "more than 3"; fi
if [[ "$COUNT" -eq 5 ]]; then echo "exactly 5"; fi

The -eq/-ne/-lt/-le/-gt/-ge operators work in [ ] and [[ ]]. They are integer-only. For floating-point comparison you need awk or bc.

Never mix them up. -eq compares numbers, == compares strings. [[ "08" -eq 8 ]] is true (both are the number eight) but [[ "08" == 8 ]] is false (different text). And [[ 10 > 9 ]] is false — inside [[ ]], > is lexicographic, and the character "1" sorts before "9". For “10 is greater than 9” you must use a numeric test: (( 10 > 9 )) or [[ 10 -gt 9 ]].

An alternative for numbers: the (( )) arithmetic command:

if (( COUNT > 3 )); then echo "more than 3"; fi
if (( COUNT == 5 )); then echo "exactly 5"; fi

(( )) is the arithmetic command — separate from $(( )) which is arithmetic expansion. It evaluates the C-style expression and returns 0 (success) if the result is non-zero, 1 (failure) if zero. It’s the cleanest way to do numeric comparisons in bash.

i=0
while (( i < 10 )); do
  echo "$i"
  (( i++ ))
done

Inside (( )), you don’t prefix variables with $i works like in C. You can use +, -, *, /, %, **, ==, !=, <, >, <=, >=, &&, ||, !, bitwise operators, ++, --, ternary ? :. Use (( )) for numeric tests in bash. Use [[ ... -eq ... ]] for POSIX-only constraints.

Logical operators inside [[ ]]

if [[ -f "$FILE" && -r "$FILE" ]]; then
  echo "file exists and is readable"
fi

if [[ "$NAME" == "alice" || "$NAME" == "bob" ]]; then
  echo "alice or bob"
fi

if [[ ! -f "$FILE" ]]; then
  echo "file does not exist"
fi

In [ ] you’d have to write [ -f "$FILE" ] && [ -r "$FILE" ] — two separate command invocations chained at the shell level. In [[ ]] it’s one expression. Cleaner, faster.


4. The =~ operator: regex matching in bash

[[ STRING =~ REGEX ]] performs ERE (Extended Regular Expression) matching. This is one of the most powerful shell features and most beginners don’t know it exists.

EMAIL="alice@example.com"
if [[ "$EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
  echo "looks like an email"
fi

VERSION="v1.2.3"
if [[ "$VERSION" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
  MAJOR="${BASH_REMATCH[1]}"
  MINOR="${BASH_REMATCH[2]}"
  PATCH="${BASH_REMATCH[3]}"
  echo "Major: $MAJOR, Minor: $MINOR, Patch: $PATCH"
fi

BASH_REMATCH is a special array set by =~ after a successful match. Index 0 is the full match; indices 1, 2, 3 are the capture groups. This is enormously useful for parsing structured strings.

The single critical rule: the regex on the RHS must NOT be quoted. If you quote it, it becomes a literal string match:

if [[ "$VERSION" =~ "^v[0-9]" ]]; then ... fi    # literal — matches strings containing "^v[0-9]"
if [[ "$VERSION" =~ ^v[0-9] ]]; then ... fi      # regex — matches strings starting with "v" then digit

If you need a literal character class (e.g. . to match a literal dot), put it in a variable and use the variable unquoted:

PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
if [[ "$VERSION" =~ $PATTERN ]]; then echo "version-like"; fi

This pattern (capture into variable, expand unquoted) is the safest way to write complex regexes — it sidesteps every quoting subtlety inside [[ ]].


5. && and || chaining vs if blocks — a critical distinction

Bash gives you two ways to express “do B if A succeeds”:

A && B

vs.

if A; then B; fi

These are almost equivalent. The exit code is the same. The behaviour is the same in most cases. But there is one critical difference, and it interacts with set -e.

With set -e:

This means the following two snippets behave differently under set -e:

set -e

# snippet 1: chain
my-tool && echo "ok" || echo "fail"

# snippet 2: if
if my-tool; then echo "ok"; else echo "fail"; fi

The chain form has a notorious gotcha: A && B || C does not mean “if A then B else C” the way it would in C. It means “if A succeeds and B succeeds, you’re done; if either A or B fails, run C.” So if B fails, C runs even though A succeeded. This is rarely what you want.

The rule: use && and || only for two-step chains. For three branches or anything more complex, use if.

The cleanest uses of chaining:

mkdir -p /tmp/cache && cd /tmp/cache    # cd only if mkdir succeeded
command -v jq >/dev/null || { echo "jq not installed" >&2; exit 1; }   # die if missing

These are short, single-purpose, and unambiguous. Anything more complex deserves a real if.

command -v is the right “is this binary installed?” check

You’ll see scripts that check for a binary like this:

which jq         # WRONG-ISH — `which` is non-standard, output varies by distro, exit code unreliable
type jq          # works but verbose output
hash jq          # works but unintuitive
command -v jq    # correct, POSIX, returns 0 if found

Use command -v jq >/dev/null as the canonical “is it installed and on PATH?” test.


6. The case statement — pattern matching done right

For three or more branches based on a single value, case is dramatically cleaner than nested if-elif-else:

case "$1" in
  start)
    start_service
    ;;
  stop)
    stop_service
    ;;
  restart|reload)
    stop_service
    start_service
    ;;
  status)
    status_service
    ;;
  *)
    echo "Usage: $0 {start|stop|restart|status}" >&2
    exit 2
    ;;
esac

The patterns on the left of each branch are globs — same syntax as *.txt, [Yy]es, etc. They’re not regexes. The | separates alternatives. The terminating ;; means “stop here, don’t fall through to the next pattern.”

Glob patterns let you do prefix/suffix matching without regex:

case "$FILE" in
  *.tar.gz|*.tgz)
    tar -xzf "$FILE"
    ;;
  *.tar.bz2|*.tbz2)
    tar -xjf "$FILE"
    ;;
  *.zip)
    unzip "$FILE"
    ;;
  *)
    echo "Unknown archive type: $FILE" >&2
    exit 1
    ;;
esac

In bash 4+, you can also use ;& (fall through to next branch) and ;;& (continue evaluating subsequent patterns):

case "$INPUT" in
  [yY]es)
    echo "Got yes"
    ;;&        # also evaluate the next pattern
  [yY]*)
    echo "Starts with y"
    ;;
esac
# If INPUT is "yes", you'd see both messages.

Use these sparingly — falls-through cases are confusing. The default ;; is almost always what you want.

Use case for argument dispatch

The classic shell pattern is parsing a CLI argument:

case "${1:-}" in
  -h|--help)
    show_help
    exit 0
    ;;
  -v|--version)
    echo "1.0.0"
    exit 0
    ;;
  --verbose)
    VERBOSE=1
    ;;
  *)
    echo "Unknown option: $1" >&2
    exit 2
    ;;
esac

Lesson 17 (argument parsing) covers getopts and full long-option support; for now case "${1:-}" is the lightweight pattern.


7. Status propagation: how to make scripts return meaningful exit codes

A script’s exit code is the exit code of its last command, unless you call exit N explicitly. Best practice: make every script’s exit code meaningful.

#!/usr/bin/env bash
set -euo pipefail

main() {
  if ! curl -fsS https://api.example.com/health >/dev/null; then
    echo "Health check failed" >&2
    return 1
  fi
  if ! check_disk_space; then
    echo "Low disk space" >&2
    return 2
  fi
  echo "All OK"
  return 0
}

main "$@"
exit $?

The exit $? at the end is explicit — it says “exit with whatever code main returned.” Without it, the script would still exit with main’s code (because main was the last command), but the explicit form documents intent.

Convention for shell-script exit codes:

Avoid using codes 126, 127, and 128+ for your own application errors — those slots are reserved by the shell.

Inverting an exit code

If you want to negate an exit code (treat success as failure and vice versa):

if ! grep -q ERROR /var/log/app.log; then
  echo "no errors found"
fi

The leading ! inverts the exit code. This is the cleanest “if NOT” form. Note that ! does not trigger set -e even if the underlying command fails! is documented as a context where errexit is suppressed. So this is safe:

set -e
if ! my-tool; then
  recover
fi

If my-tool fails, ! inverts to 0, the if takes the then branch, and set -e does not fire.


8. trap ERR — catching errors anywhere in a script

Lesson 10 covers signal handling and trap in depth, but for conditionals it’s worth knowing now: bash supports a pseudo-signal called ERR that fires whenever a command exits non-zero (subject to the same suppression rules as set -e).

#!/usr/bin/env bash
set -euo pipefail

on_error() {
  local lineno="$1"
  local code="$2"
  echo "ERROR at line ${lineno} with exit code ${code}" >&2
}

trap 'on_error ${LINENO} $?' ERR

# ... rest of script ...

This gives you a stack-trace-like behaviour. ${LINENO} is automatically set to the line number of the failing command. $? is the exit code. We’ll combine this with EXIT traps for cleanup in lesson 10.


9. Ten conditional idioms to memorise

# 1. File existence
[[ -f "$FILE" ]] && echo "exists"

# 2. Variable empty / non-empty
[[ -z "$VAR" ]] && echo "empty"
[[ -n "$VAR" ]] && echo "not empty"

# 3. String equality (exact)
[[ "$NAME" == "alice" ]]

# 4. String prefix / suffix (glob)
[[ "$FILE" == *.log ]]
[[ "$URL" == https://* ]]

# 5. Numeric comparison
(( COUNT > 3 ))

# 6. Regex match with capture
[[ "$VERSION" =~ ^v([0-9]+)\.([0-9]+) ]] && MAJOR="${BASH_REMATCH[1]}"

# 7. Command available?
command -v jq >/dev/null || { echo "jq required" >&2; exit 1; }

# 8. Run only if previous succeeded
make build && make deploy

# 9. Run only if previous failed
my-tool || retry

# 10. Default-or-die for required input
: "${DATABASE_URL:?DATABASE_URL must be set}"

These ten patterns cover 95% of every conditional you’ll write. Keep them in your fingers.


10. A complete, idiomatic example

#!/usr/bin/env bash
# verify-deployment.sh
# Smoke-tests a freshly-deployed service. Returns:
#   0 — all checks pass
#   1 — health check failed
#   2 — required env missing
#   3 — required tool missing
set -euo pipefail
IFS=$'\n\t'

# 1. Required tools
for tool in curl jq; do
  command -v "$tool" >/dev/null || { echo "Missing: $tool" >&2; exit 3; }
done

# 2. Required environment
: "${SERVICE_URL:?SERVICE_URL must be set}"
: "${EXPECTED_VERSION:?EXPECTED_VERSION must be set}"

# 3. Health check
echo "Checking ${SERVICE_URL}/health..."
if ! curl -fsS "${SERVICE_URL}/health" >/dev/null; then
  echo "Health check failed" >&2
  exit 1
fi

# 4. Version check
VERSION="$(curl -fsS "${SERVICE_URL}/version" | jq -r '.version')"
if [[ "$VERSION" != "$EXPECTED_VERSION" ]]; then
  echo "Version mismatch: got ${VERSION}, expected ${EXPECTED_VERSION}" >&2
  exit 1
fi

# 5. Pattern check on response
ENVELOPE="$(curl -fsS "${SERVICE_URL}/api/info")"
if ! [[ "$ENVELOPE" =~ \"status\":[[:space:]]*\"ok\" ]]; then
  echo "Bad info response" >&2
  exit 1
fi

# 6. Optional latency check
LATENCY_MS="$(curl -fsS -o /dev/null -w '%{time_total}' "${SERVICE_URL}/health" | awk '{print int($1 * 1000)}')"
if (( LATENCY_MS > 500 )); then
  echo "Warning: latency ${LATENCY_MS}ms exceeds 500ms threshold" >&2
fi

echo "All checks passed (version=${VERSION}, latency=${LATENCY_MS}ms)"
exit 0

Things to notice:

This is what production-grade shell looks like. Every shell script you ship should be roughly this shape.


11. What you must internalise before lesson 4

Before moving on, make sure all of these are in your reflexes:

If any of those felt fuzzy, re-read the relevant section. Lesson 4 (loops and substitution) is where conditionals start composing into real iteration patterns — and where set -e’s subtle exceptions inside while and until will come up again.


Going deeper

You now have the working model. This section is the internals, edge cases, and production nuances that separate a script that “works on my machine” from one that behaves correctly under strict mode, on other shells, and with hostile input.

Where set -e deliberately does nothing

set -e (errexit) is the most misunderstood option in bash, precisely because it has a long list of contexts where it does not fire. A command’s non-zero status is ignored by set -e when the command is:

This is by design: those are exactly the places where “failure” is a normal, expected answer you’re branching on. The practical upshot is that strict mode does not turn every non-zero into an abort — it aborts only on unhandled failures. Two consequences bite people:

First, a function called in a condition loses errexit inside it, not just for its own return. if my_func; then … runs the entire body of my_func with errexit effectively suspended, so a failing command halfway through my_func will not stop it. If you want a function to abort internally, don’t call it as an if condition — call it plainly and check afterwards, or re-assert behaviour explicitly.

Second, the local/declare masking trap. This one silently defeats set -e:

set -e
foo() {
  local result="$(command_that_fails)"   # exit status is LOCAL's (0), not the command's!
  echo "$result"
}

local result="$(…)" is really two things: declaring result, and running the command substitution. The exit status of the whole line is the status of local (which succeeds), so the failure of command_that_fails is thrown away and set -e never sees it. The fix is to split the declaration from the assignment:

set -e
foo() {
  local result
  result="$(command_that_fails)"   # now the assignment carries the substitution's status
  echo "$result"
}

Because of quirks like these, seasoned scripters treat set -e as one safety layer, not the whole strategy — they pair it with explicit checks and trap ERR. The deep dive on strict mode and static analysis is in Defensive scripting: set -euo pipefail & ShellCheck.

The (( )) “returns 1 when zero” trap

(( expr )) returns exit status 1 when the expression evaluates to 0, and 0 otherwise. That’s correct and useful for while (( n )) style loops — but it turns a handful of innocent-looking lines into landmines under set -e:

count=0
(( count++ ))      # post-increment YIELDS the OLD value (0) → the command returns 1

count++ is post-increment: the expression’s value is the value before incrementing, i.e. 0, so (( count++ )) returns 1. count does become 1, but the command reported failure. Under strict mode on Linux (bash 4+/5), that failure can abort the script; as the last statement of a function it makes the function “return failure.” (Older bash like macOS’s stock 3.2 has looser errexit here, which is exactly why you should not rely on any one host’s behaviour — write code that is safe on the strictest target.) ShellCheck flags this as SC2219. The safe forms:

(( ++count ))        # pre-increment yields the NEW value (1) → returns 0. Safe.
count=$(( count+1 )) # plain assignment; the assignment always succeeds. Safest & POSIX.
(( count++ )) || true # explicitly swallow the status when you truly want post-increment

Rule of thumb: use (( )) freely inside an if/while condition (where its status is the whole point), but when you want it purely for its side effect (incrementing a counter), prefer count=$(( count+1 )) or ((++count)) so a zero result can’t masquerade as an error.

Pipeline status and PIPESTATUS

By default a pipeline’s exit status is the status of its last command only:

grep pattern huge.log | head -1      # if grep fails but head succeeds, status is 0

set -o pipefail changes this so the pipeline fails if any stage fails (it reports the rightmost non-zero status). But even without pipefail, bash records every stage’s status in the PIPESTATUS array:

false | true | false
echo "${PIPESTATUS[@]}"    # 1 0 1   — status of each stage, left to right

PIPESTATUS is volatile like $? — it reflects the most recent pipeline and is overwritten by the next command, so read it immediately. This is how you tell “the producer died” from “the consumer died” in a cmd | filter pair. The full treatment of pipefail and SIGPIPE is in Pipes, pipelines, pipefail & SIGPIPE.

Performance: keywords don’t fork

[[ ]], (( )), [ ] (as a builtin), and case all run in-process — no fork/exec, no new process. The external /usr/bin/test and /usr/bin/[ binaries exist for POSIX shells that lack the builtin, but modern bash uses its builtins, so a [[ ]] test costs essentially nothing. This matters when you’re testing inside a tight loop over thousands of items: a case "$x" in *.log) … or [[ $x == *.log ]] runs at memory speed, whereas shelling out to echo "$x" | grep -q '\.log$' forks two processes per iteration. The idiomatic rule — stay in the shell for simple string/number decisions, reach for grep/awk/sed only when the matching genuinely needs them — is as much a performance rule as a style one, and it’s covered end-to-end in the performance & profiling lesson (fork, exec, and leaving the shell).

Security: quoting, regex metacharacters, and untrusted input

Conditionals are a classic injection surface. Two rules:

  1. Always quote the left-hand operand, even in [[ ]] where word-splitting is off — because for =~ and == an unquoted expansion can still surprise you, and quoting is a habit you never want to break in [ ]. In old-style [ ], an unquoted $USER_INPUT containing -f or = or ! can turn a data value into a test operator — a genuine logic-injection bug. [ "$x" = "$y" ] (quoted) is safe; [ $x = $y ] (unquoted) is not.

  2. Never interpolate untrusted text into the regex position of =~. The right side is code, not data. If a user controls the pattern, they control the match — and a pathological pattern against a large string can cause catastrophic backtracking (a ReDoS-style stall). When the pattern comes from outside, treat the input as a literal instead: compare with == using a quoted RHS, or fixed-string grep -F. Put trusted patterns in a variable and expand it unquoted ([[ $x =~ $KNOWN_PATTERN ]]); keep user strings on the left, as data. Input validation and quoting attacks get their own lesson on shell security: injection, quoting, IFS attacks, and input validation.

Portability: what breaks under #!/bin/sh

If your shebang is #!/bin/sh (dash, ash, busybox), these bash features are gone and will error or misbehave: [[ ]], (( )) and $(( ))-as-a-command, =~, BASH_REMATCH, the </> string operators, ;&/;;& in case, and -nt/-ot/-ef are not guaranteed. The POSIX-portable substitutions:

Bash-ism POSIX-portable equivalent
[[ "$a" == "$b" ]] [ "$a" = "$b" ] (single =)
[[ -n "$x" && -f "$f" ]] [ -n "$x" ] && [ -f "$f" ]
(( a > b )) [ "$a" -gt "$b" ]
[[ "$s" =~ $re ]] echo "$s" | grep -Eq "$re"
[[ "$f" == *.log ]] case "$f" in *.log) … ;; esac

Note that case globbing is fully POSIX — which is why case is the portable workhorse for pattern branching. Detecting the running shell and gating bashisms gets its own lesson on POSIX portability versus bashisms.

trap ERR, set -E, and reporting

trap '…' ERR fires on the same non-zero events that set -e would act on (and obeys the same suppression list). But an ERR trap is not inherited by shell functions, command substitutions, or subshells unless you also set set -E (errtrace). If you want your on_error handler to fire for a failure inside a function, use set -Eeuo pipefail. Capture $? and ${LINENO} as the trap’s first actions, because any command inside the handler overwrites $?:

set -Eeuo pipefail
trap 'rc=$?; echo "failed at line ${LINENO} (exit ${rc})" >&2' ERR

Common beginner mistakes

These are misconceptions, not just typos — each is a wrong mental model, followed by the right one.


Practice challenges

Work these in order — they escalate from “read a status” to “reason about strict-mode propagation.” Try each in a real shell before expanding the solution. (This host is macOS/bash 3.2; the answers target Linux bash 4+/5, the course standard — where they differ it’s noted.)

1. (Beginner) Read the slip of paper. Run three commands — one that succeeds, one that fails normally, and one that doesn’t exist — and print each one’s exit status. Predict the three numbers before you run it.

<details> <summary>Solution</summary>

true;            echo "true -> $?"        # 0
ls /nope 2>/dev/null; echo "ls   -> $?"   # 1 or 2 (GNU ls uses 2 for a missing path)
no_such_cmd_xyz 2>/dev/null; echo "cmd  -> $?"  # 127 — command not found

Why: true is guaranteed 0, a tool that ran-but-failed reports its own code, and 127 specifically means the shell never found a program to run — a category, not a program error. </details>

2. (Beginner) Make it empty-safe. The test [ $NAME = admin ] crashes when NAME is empty. Fix it two different ways.

<details> <summary>Solution</summary>

NAME=""
[ "$NAME" = admin ] && echo yes    # fix 1: quote the operand → [ "" = admin ]
[[ $NAME == admin ]] && echo yes   # fix 2: [[ ]] never word-splits, so quoting is optional here

Why: unquoted $NAME expands to nothing, leaving [ = admin ] (malformed). Quoting keeps it as one empty argument; [[ ]] sidesteps word-splitting entirely. </details>

3. (Intermediate) Dispatch on file type with case. Given a filename in $f, print gzip, bzip2, zip, or unknown based on its extension — no if chain, no grep.

<details> <summary>Solution</summary>

case "$f" in
  *.tar.gz|*.tgz)   echo gzip  ;;
  *.tar.bz2|*.tbz2) echo bzip2 ;;
  *.zip)            echo zip   ;;
  *)                echo unknown ;;
esac

Why: case patterns are globs (POSIX, in-process, no fork), and | gives you alternatives — the cleanest and most portable multi-way branch on a single value. </details>

4. (Intermediate) Parse a version with capture groups. From TAG="release-2.11.4", extract major/minor/patch into three variables using one [[ =~ ]] match.

<details> <summary>Solution</summary>

TAG="release-2.11.4"
if [[ "$TAG" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
  major="${BASH_REMATCH[1]}"; minor="${BASH_REMATCH[2]}"; patch="${BASH_REMATCH[3]}"
  echo "$major $minor $patch"     # 2 11 4
fi

Why: =~ runs an ERE and fills BASH_REMATCH — index 0 is the whole match, 1/2/3 are the capture groups. The regex is unquoted; quoting it would make it a literal string match and fail. </details>

5. (Advanced) Defuse the A && B || C trap. This “if/else” wrongly prints both lines when the deploy step fails after a successful build. Explain why, then rewrite it so rollback runs only when build fails.

build && deploy || rollback

<details> <summary>Solution</summary>

The chain runs rollback whenever either build or deploy fails — so a failed deploy (after a good build) still triggers rollback, and worse, if you added an echo ok it could run alongside the error path. Use a real if:

if build; then
  deploy
else
  rollback
fi

Why: &&/|| chains have no notion of “else” — || C fires on any preceding non-zero. Three outcomes (build-ok+deploy, build-fail+rollback) need an if/then/else. </details>

6. (Advanced) Find the strict-mode landmine. This function is meant to abort the script if the download fails, but under set -e it sails on with an empty data. Identify the bug and give the one-line fix.

set -euo pipefail
fetch() {
  local data="$(curl -fsS "$1")"   # <-- bug
  printf '%s' "$data"
}

<details> <summary>Solution</summary>

local data="$(…)" runs two things: the local builtin (which succeeds) and the command substitution. The line’s exit status is local’s success, so curl’s failure is discarded and set -e never fires. Split the declaration from the assignment:

fetch() {
  local data
  data="$(curl -fsS "$1")"   # now the assignment carries curl's status → set -e can fire
  printf '%s' "$data"
}

Why: an assignment’s status is the status of its command substitution, but a local/declare declaration’s status is the builtin’s — which masks the failure. (Bonus trap: (( count++ )) from 0 returns 1 for the same “status you didn’t expect” reason — prefer ((++count)) or count=$((count+1)).) </details>


Glossary


What’s next

Lesson 4 covers for, while, until, case, break, continue, the C-style for ((i=0;i<10;i++)) form, the mapfile/readarray idioms for line-by-line iteration without word-splitting bugs, and the most common iteration anti-pattern in shell (for f in $(ls)) and how to replace it. Bring everything from lessons 1–3.

shellbashconditionalsexit-codestestifcaseregexcontrol-flowfundamentalslinuxposix
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments