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.
Exit codes are the condition
Section titled “Exit codes are the condition”Every command returns an integer status when it finishes. 0 means success; anything from 1 to 255
means failure. It is stored in $?.
ls /etc >/dev/null ; echo "$?" # => 0ls /nope 2>/dev/null ; echo "$?" # => 2This 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.
test, [ ], and [[ ]]
Section titled “test, [ ], and [[ ]]”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:
[ "$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 |
f=""[ -n $f ] && echo yes # becomes `[ -n ]` -> true! wrong answer[[ -n $f ]] && echo yes # correctly falseUse [[ ]] in bash scripts. Use [ ] only when you are writing for #!/bin/sh.
String comparisons
Section titled “String comparisons”[[ "$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 collationInside [[ ]] an unquoted right-hand side of == is a glob pattern; quoting makes it literal:
file="notes.txt"[[ $file == *.txt ]] # true — pattern match[[ $file == "*.txt" ]] # false — literal comparison[[ $file == n?tes.* ]] # trueRegex 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:
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.
Numeric comparisons
Section titled “Numeric comparisons”[[ ]] 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:
if (( count > 10 && retries < 3 )); then ...; fiif (( $(date +%H) < 12 )); then echo "morning"; fiFile tests
Section titled “File tests”[[ -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[[ -f "$config" ]] || { echo "no config at $config" >&2; exit 1; }[[ -d "$out" ]] || mkdir -p "$out"if / elif / else
Section titled “if / elif / else”if [[ -f "$file" ]]; then echo "regular file"elif [[ -d "$file" ]]; then echo "directory"else echo "something else, or missing"fiThe ; 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:
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 1fi
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.
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 lastesacPatterns 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
case "$level" in debug) echo "DEBUG on" ;;& # also consider later patterns debug|info) echo "INFO on" ;; *) echo "quiet" ;;esacfor over a list
Section titled “for over a list”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 rangefor i in {0..20..5}; do echo "$i"; done # with a step (bash 4.0+)for host in "${servers[@]}"; do ssh "$host" uptime; doneBrace expansion happens before variable expansion, so {1..$n} does not work. Use a C-style
loop or seq.
C-style for
Section titled “C-style for”for (( i = 0; i < 10; i++ )); do echo "$i"done
for (( i = ${#arr[@]} - 1; i >= 0; i-- )); do # reverse iteration echo "${arr[i]}"donewhile and until
Section titled “while and until”count=0while (( 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 2done
while true; do do_work || break sleep 60doneA retry loop worth stealing:
attempt=0; max=5until curl -fsS "$url" -o out.json; do (( ++attempt >= max )) && { echo "giving up after $max tries" >&2; exit 1; } sleep $(( 2 ** attempt )) # exponential backoffdoneReading a file line by line
Section titled “Reading a file line by line”This is the one loop everyone gets wrong. The correct form:
while IFS= read -r line; do echo "[$line]"done < input.txtEach part is load-bearing:
IFS=(empty, set only for this command) stops leading/trailing whitespace being stripped.-rstops backslashes being interpreted as escapes.< input.txtredirects the loop, not theread— so the file stays open across iterations.
Splitting fields as you read:
while IFS=: read -r user _ uid _ _ home shell; do printf '%-12s %-6s %s\n' "$user" "$uid" "$shell"done < /etc/passwdReading NUL-separated names (the only safe way to handle arbitrary filenames):
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:
while IFS= read -r -u 3 host; do ssh "$host" uptimedone 3< hosts.txtbreak and continue
Section titled “break and continue”for f in *; do [[ -d "$f" ]] && continue # skip directories [[ "$f" == stop ]] && break # leave the loopdone
for a in 1 2 3; do for b in x y; do [[ "$b" == y ]] && continue 2 # continue the OUTER loop donedoneThe numeric argument counts loop levels outward, defaulting to 1.
Functions
Section titled “Functions”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):
counter=0bump() { local step="${1:-1}" # local: invisible outside counter=$(( counter + step )) # deliberately global}bump 5; echo "$counter" # => 5return vs echo
Section titled “return vs echo”return sets an exit status (0–255), not a value. To return data, print it and capture it:
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) # => 9Because 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+):
get_config() { local -n out=$1 # out becomes an alias for the caller's variable out="loaded value"}get_config resultecho "$result" # => loaded valueRemember 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).
&& and ||
Section titled “&& and ||”These chain commands on the previous status, short-circuiting like their C counterparts.
mkdir -p build && cd build # cd only if mkdir succeededcmd || echo "failed" >&2 # run only on failurecommand -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.
Key points
Section titled “Key points”- 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. casematches 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
whileloop puts it in a subshell; use< <(cmd)to keep variables. - Functions return a status with
returnand a value by printing to stdout; declare working variableslocal.