Conditionals and exit codes
Every command exits with a numeric code: 0 means success, non-zero means failure. $? holds the last exit code. [[ ]] is the modern bash test construct; && and || chain commands on success or failure.
Every command you run tells the shell whether it succeeded by exiting with a number. Zero means success; anything else means failure. This is not a convention — it is the protocol that if, &&, ||, and set -e all rely on. Scripts that ignore exit codes are scripts that silently do the wrong thing when a dependency is missing, a file is absent, or a network call fails. Once you treat exit codes as the control signal they are, you stop writing scripts that “work on my machine” and start writing scripts that fail loudly and early.
After this lesson you can read $? to inspect the last exit code, use [[ ]] for safe conditional tests, write if/elif/else branches, chain commands with && and ||, and understand why [ ] and [[ ]] are not the same.
Every command exits with a code in $?. 0 = success. 1–255 = failure (specific meanings vary by program). Read it immediately after the command — the next command overwrites it.
ls /etc/passwd
echo "exit code: $?" # prints: exit code: 0
ls /nonexistent
echo "exit code: $?" # prints: exit code: 2grep exits 0 if it found at least one match, 1 if no match, 2 on error. ssh exits 0 if the connection and remote command both succeeded. Every well-written Unix tool follows this contract.
[[ condition ]] is the modern bash test construct. It evaluates a condition and exits 0 (true) or 1 (false). Always put spaces inside the brackets.
file="/etc/passwd"
[[ -f "$file" ]] && echo "exists" # -f: regular file exists
[[ -d "/tmp" ]] && echo "is a dir" # -d: directory exists
[[ -r "$file" ]] && echo "readable" # -r: readable by current user
x=5
[[ $x -gt 3 ]] && echo "greater" # -gt: numeric greater-than
[[ "$x" == "5" ]] && echo "equal" # ==: string equality (glob pattern on right)
[[ "$x" =~ ^[0-9]+$ ]] && echo "numeric" # =~: regex match (bash 3.2+)Common file tests: -f (file), -d (dir), -e (exists), -r (readable), -w (writable), -x (executable), -s (non-empty), -L (symlink).
&& runs the right side only if the left side exited 0. || runs the right side only if the left side exited non-zero. These are the most common conditional patterns in one-liners and scripts.
mkdir -p /tmp/work && echo "created" # echo only if mkdir succeeded
cd /nonexistent || { echo "failed" >&2; exit 1; } # exit on cd failure|| is the “or-die” pattern — chain it after any command that must succeed for the script to continue. The { ...; } grouping lets you run multiple commands on failure.
You can chain both:
cp "$src" "$dst" && echo "ok" || echo "FAILED"Caution: if echo "ok" itself fails (unlikely but possible), the || arm also fires. For reliable error handling, prefer if.
if tests an exit code, not a boolean value. Any command can be the condition.
if [[ -f "$config" ]]; then
source "$config"
elif [[ -f "${config}.default" ]]; then
source "${config}.default"
else
echo "No config found" >&2
exit 1
fiif cmd; then ... fi executes cmd, and if its exit code is 0, runs the then block. The condition is always a command — [[ ]] is just a command that exits 0 or 1. You can use any command:
if grep -q "error" /var/log/app.log; then
echo "errors found in log"
figrep -q suppresses output and exits 0 if the pattern was found — a clean way to test file content without capturing output.
[ ] (POSIX test) is older and less safe than [[ ]]. The critical difference: [ ] performs word-splitting on unquoted variables; an empty or multi-word variable causes a syntax error or wrong result.
x=""
[ $x = "yes" ] # WRONG — expands to: [ = "yes" ] — syntax error
[[ $x == "yes" ]] # safe — [[ ]] never word-splits its argumentsIn [[ ]], the right side of == is a glob pattern, not a literal string. To compare literally, quote the right side: [[ "$x" == "yes" ]]. For regex, use =~ without quoting the pattern.
Use [[ ]] in all bash scripts. Use [ ] only when writing POSIX sh scripts that must run on shells that lack [[ ]].
Guard a deploy script with exit-code checks.
#!/usr/bin/env bash
TARGET="$1"
[[ -z "$TARGET" ]] && { echo "Usage: $0 <host>" >&2; exit 1; }
if ! ping -c1 -W2 "$TARGET" &>/dev/null; then
echo "Host $TARGET unreachable" >&2
exit 1
fi
if ! ssh "$TARGET" "test -d /var/app"; then
echo "App directory missing on $TARGET" >&2
exit 2
fi
rsync -az ./dist/ "$TARGET":/var/app/ && echo "Deploy complete."[[ -z "$TARGET" ]] tests for an empty string. ! ping ... inverts the exit code — the block runs when ping fails. Each failure path prints to stderr (>&2) and exits with a distinct code so the caller can distinguish “unreachable” from “missing directory.”
▸Why this works
On macOS with bash 3.2, [[ ]] and &&/|| work identically to modern bash. The one portability note: =~ regex matching in [[ ]] is available in bash 3.2, but the regex engine on macOS is the BSD ERE (POSIX extended), which lacks some Perl-isms. Stick to basic ERE patterns (^, $, [0-9]+, (a|b)) and your scripts will work on both Linux and macOS bash 3.2.
▸Common mistake
A classic trap: [ $var == value ] with an empty $var becomes [ == value ], which is a syntax error. Another: comparing numbers with string operators. [[ "10" > "9" ]] is true in numeric terms but false as a string comparison (because "1" sorts before "9" lexicographically). For numeric comparisons always use -gt, -lt, -eq, -ne, -ge, -le inside [[ ]], or use (( )) arithmetic: (( x > 9 )).
What is the exit code of a command that succeeds in bash?
Every command produces an exit code in $?: 0 is success, non-zero is failure. [[ ]] is the safe bash test construct — it never word-splits its operands, supports =~ regex matching, and handles empty variables gracefully. [ ] is the POSIX fallback; avoid it in bash scripts. && short-circuits and runs the right command only on success; || runs only on failure — together they enable one-liner guard clauses. if cmd; then ... fi evaluates any command’s exit code as its condition. Always send error messages to stderr with >&2 and exit with a non-zero code so callers can detect failure.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.