Skip to content

Control Flow

Bash has no booleans. Every branch and every loop is driven by the exit status of a command, and once that clicks, if, while, &&, and [[ ]] all stop looking arbitrary.

Every command returns an integer status when it finishes. 0 means success; anything from 1 to 255 means failure. It is stored in $?.

Terminal window
ls /etc >/dev/null ; echo "$?" # => 0
ls /nope 2>/dev/null ; echo "$?" # => 2

This is inverted relative to most languages — think “zero problems” rather than “false”.

Two builtins exist purely to produce statuses: true (always 0) and false (always 1). And : is a no-op that also returns 0.

Status Conventional meaning
0 Success
1 General failure
2 Misuse / bad arguments (many GNU tools)
126 Found, but not executable (permissions)
127 Command not found
128+N Killed by signal N (130 = Ctrl-C / SIGINT, 143 = SIGTERM)
255 Out-of-range exit value

Set your own with exit N (ends the script) or return N (ends a function). Only the low 8 bits survive: exit 256 reports 0.

if takes a command, not an expression. [ is not punctuation — it is a real command (a bash builtin, and also a file at /usr/bin/[) whose last argument must be ]. That is why the spaces are mandatory:

Terminal window
[ "$a" = "$b" ] # correct
["$a" = "$b"] # bash: command not found: [a
[ "$a"="$b" ] # WRONG: one argument, a non-empty string -> always true

[[ ... ]] is a shell keyword, so bash parses its contents instead of expanding them into arguments. That single difference removes a whole class of bugs.

[ ] / test [[ ]]
Kind Builtin command Shell keyword (bash/ksh/zsh, not POSIX sh)
Unquoted empty variable Syntax error Safe
Word splitting / globbing on values Yes No
&& and || inside No — use -a / -o (deprecated, ambiguous) Yes
Regex =~ No Yes
Glob pattern with == No Yes
< > string compare Needs escaping (\<) — it is redirection otherwise Works directly
Terminal window
f=""
[ -n $f ] && echo yes # becomes `[ -n ]` -> true! wrong answer
[[ -n $f ]] && echo yes # correctly false

Use [[ ]] in bash scripts. Use [ ] only when you are writing for #!/bin/sh.

Terminal window
[[ "$a" == "$b" ]] # equal (= and == are identical inside [[ ]])
[[ "$a" != "$b" ]] # not equal
[[ -z "$a" ]] # zero length (empty)
[[ -n "$a" ]] # non-empty
[[ "$a" < "$b" ]] # lexicographic, by current locale collation

Inside [[ ]] an unquoted right-hand side of == is a glob pattern; quoting makes it literal:

Terminal window
file="notes.txt"
[[ $file == *.txt ]] # true — pattern match
[[ $file == "*.txt" ]] # false — literal comparison
[[ $file == n?tes.* ]] # true

Regex matching uses =~ with POSIX extended regex. Do not quote the pattern (quoting makes it literal); put it in a variable if it is complex. Captures land in BASH_REMATCH:

Terminal window
v="v2.15.3"
if [[ $v =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}"
fi
re='^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
[[ $email =~ $re ]] || { echo "bad email" >&2; exit 1; }

See regex for the pattern syntax itself.

[[ ]] and [ ] use letter operators; (( )) uses the symbols you expect.

Meaning Bracket form Arithmetic form
equal [[ $n -eq 5 ]] (( n == 5 ))
not equal [[ $n -ne 5 ]] (( n != 5 ))
less than [[ $n -lt 5 ]] (( n < 5 ))
less or equal [[ $n -le 5 ]] (( n <= 5 ))
greater than [[ $n -gt 5 ]] (( n > 5 ))
greater or equal [[ $n -ge 5 ]] (( n >= 5 ))

Prefer (( )) for numbers — it reads better and handles arithmetic inline:

Terminal window
if (( count > 10 && retries < 3 )); then ...; fi
if (( $(date +%H) < 12 )); then echo "morning"; fi
Terminal window
[[ -e path ]] # exists (any type)
[[ -f path ]] # exists and is a regular file
[[ -d path ]] # is a directory
[[ -L path ]] # is a symlink (-e follows links, -L does not)
[[ -s path ]] # exists and is non-empty
[[ -r path ]] # readable by this user -w writable -x executable
[[ a -nt b ]] # a is newer than b (modification time)
[[ a -ot b ]] # older than
[[ a -ef b ]] # same file (same device and inode) — catches hardlinks/symlinks
Terminal window
[[ -f "$config" ]] || { echo "no config at $config" >&2; exit 1; }
[[ -d "$out" ]] || mkdir -p "$out"
Terminal window
if [[ -f "$file" ]]; then
echo "regular file"
elif [[ -d "$file" ]]; then
echo "directory"
else
echo "something else, or missing"
fi

The ; before then is just a statement separator — you can put then on its own line instead.

Because if takes any command, you rarely need [[ ]] for tool results:

Terminal window
if grep -q "ERROR" app.log; then # -q: no output, status only
echo "errors found"
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required" >&2; exit 1
fi
if curl -fsS --max-time 5 "$url" >/dev/null; then
echo "reachable"
fi

! negates the status of the command that follows it.

case matches a string against glob patterns and is the clean way to write a multi-branch dispatch.

Terminal window
case "$1" in
start) start_service ;;
stop|halt) stop_service ;; # alternation with |
restart) stop_service; start_service ;;
-h|--help) usage; exit 0 ;;
[0-9]*) echo "starts with a digit" ;;
*.tar.gz|*.tgz) tar xzf "$1" ;;
"") echo "empty argument" >&2; exit 1 ;;
*) echo "unknown: $1" >&2; exit 1 ;; # default, must be last
esac

Patterns are globs, not regexes: *, ?, [...], and | for alternatives. The *) catch-all goes last because the first match wins.

Terminators (bash 4.0+ for the last two):

  • ;; — stop here (normal)
  • ;& — fall through into the next branch’s body unconditionally
  • ;;& — continue testing the remaining patterns
Terminal window
case "$level" in
debug) echo "DEBUG on" ;;& # also consider later patterns
debug|info) echo "INFO on" ;;
*) echo "quiet" ;;
esac
Terminal window
for x in one two three; do
echo "$x"
done
for f in "$@"; do # over the script's arguments — always quoted
echo "arg: $f"
done
for f in *.log; do # over a glob
[[ -e "$f" ]] || continue # guard: a non-matching glob stays literal
echo "$f"
done
for i in {1..5}; do echo "$i"; done # brace range
for i in {0..20..5}; do echo "$i"; done # with a step (bash 4.0+)
for host in "${servers[@]}"; do ssh "$host" uptime; done

Brace expansion happens before variable expansion, so {1..$n} does not work. Use a C-style loop or seq.

Terminal window
for (( i = 0; i < 10; i++ )); do
echo "$i"
done
for (( i = ${#arr[@]} - 1; i >= 0; i-- )); do # reverse iteration
echo "${arr[i]}"
done
Terminal window
count=0
while (( count < 3 )); do
echo "attempt $count"
(( count++ ))
done
until ping -c1 -W1 "$host" >/dev/null 2>&1; do # until = loop while it FAILS
echo "waiting for $host..."
sleep 2
done
while true; do
do_work || break
sleep 60
done

A retry loop worth stealing:

Terminal window
attempt=0; max=5
until curl -fsS "$url" -o out.json; do
(( ++attempt >= max )) && { echo "giving up after $max tries" >&2; exit 1; }
sleep $(( 2 ** attempt )) # exponential backoff
done

This is the one loop everyone gets wrong. The correct form:

Terminal window
while IFS= read -r line; do
echo "[$line]"
done < input.txt

Each part is load-bearing:

  • IFS= (empty, set only for this command) stops leading/trailing whitespace being stripped.
  • -r stops backslashes being interpreted as escapes.
  • < input.txt redirects the loop, not the read — so the file stays open across iterations.

Splitting fields as you read:

Terminal window
while IFS=: read -r user _ uid _ _ home shell; do
printf '%-12s %-6s %s\n' "$user" "$uid" "$shell"
done < /etc/passwd

Reading NUL-separated names (the only safe way to handle arbitrary filenames):

Terminal window
while IFS= read -r -d '' f; do
echo "processing: $f"
done < <(find . -type f -name '*.log' -print0)

If the loop body itself reads stdin (e.g. calls ssh), it will eat your input. Give read its own descriptor:

Terminal window
while IFS= read -r -u 3 host; do
ssh "$host" uptime
done 3< hosts.txt
Terminal window
for f in *; do
[[ -d "$f" ]] && continue # skip directories
[[ "$f" == stop ]] && break # leave the loop
done
for a in 1 2 3; do
for b in x y; do
[[ "$b" == y ]] && continue 2 # continue the OUTER loop
done
done

The numeric argument counts loop levels outward, defaulting to 1.

Terminal window
greet() { # preferred form; the `function` keyword is a bash-only alias
local name="${1:-world}"
printf 'Hello, %s!\n' "$name"
}
greet # => Hello, world!
greet Ada # => Hello, Ada!

Functions are called like commands, not like greet(Ada). Inside, $1, $2, $#, and "$@" refer to the function’s arguments, shadowing the script’s. $0 does not change.

Every variable in bash is global unless you say otherwise. local limits it to the function and everything the function calls (dynamic scoping):

Terminal window
counter=0
bump() {
local step="${1:-1}" # local: invisible outside
counter=$(( counter + step )) # deliberately global
}
bump 5; echo "$counter" # => 5

return sets an exit status (0–255), not a value. To return data, print it and capture it:

Terminal window
is_even() { # status-style: use it in a condition
(( $1 % 2 == 0 ))
}
is_even 4 && echo "even"
max() { # value-style: print, then capture
local a=$1 b=$2
(( a > b )) && echo "$a" || echo "$b"
}
biggest=$(max 3 9) # => 9

Because capturing uses a subshell, a value-style function cannot also modify globals. The fast alternative is to assign to a named variable — with a nameref (bash 4.3+):

Terminal window
get_config() {
local -n out=$1 # out becomes an alias for the caller's variable
out="loaded value"
}
get_config result
echo "$result" # => loaded value

Remember that anything a function prints to stdout becomes part of its “return value” when captured — send progress messages to stderr with >&2.

Other details: functions must be defined before they are called (the file is read top to bottom); unset -f name removes one; declare -f name prints its source; a function may have the same name as an external command, and command name bypasses it (see command precedence).

These chain commands on the previous status, short-circuiting like their C counterparts.

Terminal window
mkdir -p build && cd build # cd only if mkdir succeeded
cmd || echo "failed" >&2 # run only on failure
command -v jq >/dev/null || { echo "install jq" >&2; exit 1; }

The { ...; } grouping runs multiple commands in the current shell; the semicolon before } and the spaces inside the braces are required.

A common set -e interaction: a command on the left of &&/|| is in a “tested” context, so its failure does not abort the script. That makes check || die "..." idiomatic, and it also means grep -q x file && do_thing silently does nothing when grep finds nothing — often what you want, but be deliberate about it.

  • Exit status 0 is success; if, while, && all just read that status.
  • Use [[ ]] in bash: no word splitting, real &&/||, glob ==, regex =~.
  • Use (( )) for numeric conditions and arithmetic.
  • case matches globs and is the right tool for command dispatch.
  • The correct file loop is while IFS= read -r line; do ...; done < file.
  • Piping into a while loop puts it in a subshell; use < <(cmd) to keep variables.
  • Functions return a status with return and a value by printing to stdout; declare working variables local.