Flow Control: Branching With If
4 min read
Core idea
Branching in bash is not "if (boolean)" — it is "if a command succeeded". The shell decides which branch to take by inspecting the exit status of whatever command follows if. Exit status 0 means success and triggers the then branch; anything else (1 through 255) is failure and triggers the else. To write rich predicates, bash provides three small expression languages — test / [ ], the modern [[ ]], and the integer-only (( )) — each of which is a command whose exit status reflects whether its expression evaluated to true.
Author's framing: What the if statement really does is evaluate the success or failure of commands. Every other notion of "true" in shell is layered on top of that one fact.
Exit status is the lingua franca
Every command terminates with an integer status from 0 to 255. By convention 0 is success — anything else is failure. The shell's special parameter $? holds the most recent command's status; if consumes that status implicitly. Even true and false are real commands that exist solely to return 0 and 1. Recognising this means if grep -q pattern file and if [ "$x" -eq 5 ] are the same construct — they both run a command and branch on its status.
test / [ ] — the portable expression language
test EXPR and [ EXPR ] are two spellings of the same built-in command. They evaluate one expression — a file check, a string compare, an integer compare — and exit with status 0 or 1. Because they are commands and not syntax, every operator (<, >, parentheses) must be quoted or escaped to keep the shell from interpreting them as redirection or grouping. test works in every POSIX shell, which is why it shows up in system init scripts.
[[ ]] — bash's modern predicate
[[ EXPR ]] is a compound command built into the shell, not a parsed argument list. That means <, >, &&, ||, and parentheses inside [[ ]] mean exactly what you'd hope — no quoting required. [[ ]] adds two features test lacks:
=~for regex matching ([[ "$INT" =~ ^-?[0-9]+$ ]]),- pathname pattern matching on
==([[ "$f" == *.txt ]]).
For bash scripts, [[ ]] is the default choice unless POSIX portability is a hard requirement.
(( )) — arithmetic predicates
(( EXPR )) evaluates an arithmetic expression and exits 0 when the result is non-zero (true in C-style). Inside (( )), variables are bare names (no $) and operators are the familiar ==, !=, <, >, <=, >=, &&, ||. For integer comparisons this is far more readable than [ "$x" -gt 5 ] — and it is the right tool for any predicate that's a calculation rather than a string check.
&& and || as short-circuit branches
Outside of [[ ]], the operators && and || between two commands are themselves a form of branching. cmd1 && cmd2 runs cmd2 only if cmd1 succeeded; cmd1 || cmd2 runs cmd2 only if cmd1 failed. These read top-to-bottom and replace many short ifs — mkdir tmp && cd tmp is one line instead of three.
Why it matters
The exit-status model is the seam where bash glues together programs written in any language. A Python script that prints "OK" and exits 0 is interchangeable with a C program that does the same. if doesn't care what's in the box — it cares whether the box closed cleanly. This is why shell pipelines compose at all.
It explains weird-looking idioms
grep -q pattern file && echo found looks alien at first, but it is the same construct as if grep -q pattern file; then echo found; fi. Reading && as "and-if-success" rather than "logical and" makes one-liners legible — and lets you write them with confidence.
It rewards quoting variables
The classic test bug — [ $x = "yes" ] blowing up when $x is empty — happens because an unquoted empty expansion turns [ = yes ] into a malformed two-operand expression. Quoting [ "$x" = "yes" ] ensures the operand is always present, even as the empty string. [[ ]] is more forgiving (its parser knows there's a left-hand side), but the habit of quoting is universally good.
It clarifies which predicate language to pick
- Strings or files? Use
[[ ]]in bash; use[ ]if you need POSIX. - Integers? Use
(( ))— natural syntax, no-eq/-ltmuddle. - Two commands chained? Use
&&/||if it fits on one line; useifif it doesn't.
Key takeaways
Mental model
Practical application
-
Pick the right predicate language. Strings or files →
[[ ]]. Integers →(( )). Need to run on dash/sh? →[ ]. When in doubt:[[ ]]. -
Always quote your variables in
[ ].[ "$x" = "yes" ]not[ $x = "yes" ].[[ ]]is more forgiving but the habit is good. -
Use
=~for input validation.[[ "$INT" =~ ^-?[0-9]+$ ]]is the canonical "is it really an integer?" check. -
Use
(( ))for numeric comparisons.(( count > 5 ))is clearer than[ "$count" -gt 5 ]. -
Chain with
elif, not nestedif. Three nested ifs is two refactors away from a function. -
Use
&&and||for one-line guards.mkdir tmp && cd tmpis idiomatic;[ -d tmp ] || mkdir tmpis the standard "ensure directory" pattern. -
Wrap negations of compound expressions in
( ).[[ ! ( $x -lt 5 && $y -gt 0 ) ]]— without the parens,!only negates the first sub-expression.
Example
Imagine a deploy script that needs to do three things in order: (1) verify the working tree is clean, (2) verify the current branch is main, (3) push. Each verification is a command whose exit status is the truth.
# 1. clean working tree?
if ! git diff --quiet HEAD; then
echo "Working tree dirty — commit or stash first." >&2
exit 1
fi
# 2. on main?
branch=$(git symbolic-ref --short HEAD)
if [[ "$branch" != "main" ]]; then
echo "Not on main (on $branch). Refusing to push." >&2
exit 1
fi
# 3. push.
git push origin main || { echo "Push failed." >&2; exit 1; }
The first if runs git diff --quiet HEAD — exit 0 if clean, exit 1 if dirty. The ! flips success into the error branch. The second uses [[ ]] for a string compare. The third uses || because the success path is empty (just continue) and the failure path is a one-liner. Three different idioms, one underlying mechanism: every branch is a question of "did the previous command return 0?"
Related lessons
Related concepts
- Control Flowlinked concept
- Exit Codeslinked concept
- Bash Scriptinglinked concept
- Input Validationlinked concept