Bash conditionals do not evaluate booleans. They run a command and look at its exit status. Once that clicks, most of the odd syntax stops being arbitrary.
Topic 1: if Tests a Command
if command; then
# ran successfully (exit 0)
else
# failed (exit non-zero)
fi
Exit 0 is true. This is inverted from most languages, and it comes from the convention that there is one way to succeed and many ways to fail.
Any command works as a condition:
if grep -q ERROR app.log; then # -q: silent, we only want the status
echo "errors present"
fi
if curl -fsS --max-time 5 "$url" >/dev/null; then
echo "endpoint healthy"
fi
if systemctl is-active --quiet nginx; then
echo "running"
fi
This is the idiomatic form — far better than capturing output and comparing it, and better than checking $? afterwards:
grep -q ERROR app.log
if [[ $? -eq 0 ]]; then ... fi # SC2181 — redundant
if grep -q ERROR app.log; then ... fi # direct
The full shape:
if [[ $count -gt 100 ]]; then
echo "high"
elif [[ $count -gt 50 ]]; then
echo "medium"
else
echo "low"
fi
elif, not else if. fi closes it. The ; before then is required when they share a line.
Short-circuit forms:
[[ -f $config ]] || { echo "missing config" >&2; exit 1; }
[[ -d $dir ]] && cd "$dir"
mkdir -p "$dir" && cd "$dir" || exit 1
&& runs the next command only on success, || only on failure. The { ...; } grouping needs the trailing semicolon and the spaces inside the braces.
Careful chaining a && b || c: if b fails, c also runs. That is not an if/else — it is three commands.
Topic 2: [ ] vs [[ ]] vs (( ))
Three test constructs, and choosing correctly removes a whole class of bug.
[ ] | [[ ]] | (( )) | |
|---|---|---|---|
| What it is | A command (/usr/bin/[, also a builtin) | A shell keyword | Arithmetic evaluation |
| Portable | POSIX — works in sh | bash/ksh/zsh only | bash/ksh/zsh only |
| Unquoted variables | Dangerous | Safe | Safe |
&& / || inside | No — use -a / -o | Yes | Yes |
< > | Redirection! Must escape | String comparison | Numeric comparison |
Regex =~ | No | Yes | No |
| Globbing on the right | No | Yes | — |
Why [ ] is dangerous with empty variables:
[ is a command, so its arguments are built by the shell before it runs. An empty unquoted variable disappears entirely:
x=""
[ $x = "test" ] # becomes: [ = test ] → "unary operator expected"
[ "$x" = "test" ] # becomes: [ "" = test ] → works
x="a b"
[ $x = "a b" ] # becomes: [ a b = a b ] → "too many arguments"
[[ ]] is parsed by the shell itself, so no word splitting happens and both work unquoted. In bash, use [[ ]]. Use [ ] only when the script must run under /bin/sh.
(( )) for numbers:
(( count > 100 )) # no $ needed, natural operators
(( count++ ))
(( total = a + b ))
if (( disk_pct >= 90 )); then echo "critical"; fi
Much more readable than [ "$count" -gt 100 ]. Remember it returns non-zero when the result is 0 — the set -e trap.
Topic 3: Comparison Operators
Numeric — inside [ ] or [[ ]]:
| Operator | Meaning |
|---|---|
-eq / -ne | equal / not equal |
-gt / -ge | greater / greater-or-equal |
-lt / -le | less / less-or-equal |
Inside (( )) use the natural ==, !=, >, >=, <, <=.
Never use -eq on strings. [[ "abc" -eq "xyz" ]] is true, because both convert to the integer 0.
String:
| Operator | Meaning |
|---|---|
= or == | Equal. In [[ ]] the right side is a glob pattern unless quoted |
!= | Not equal |
< / > | Lexicographic. Must be escaped in [ ] or it redirects |
-z | Zero length (empty) |
-n | Non-empty |
=~ | Regex match — [[ ]] only |
The glob behaviour on the right of == catches people:
[[ $file == *.log ]] # glob match — true for anything ending .log
[[ $file == "*.log" ]] # literal comparison — true only for the exact string
That is genuinely useful, and a surprise if you expected string equality.
Regex with =~:
if [[ $version =~ ^([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[0] is the whole match; [1], [2]… are the capture groups.
Do not quote the pattern. A quoted right-hand side is treated as a literal string, not a regex:
[[ $x =~ ^[0-9]+$ ]] # regex
[[ $x =~ "^[0-9]+$" ]] # literal — matches only that exact text
For a pattern in a variable, assign it first and use the bare variable:
re='^[0-9]+$'
[[ $x =~ $re ]]
Topic 4: File Tests
| Test | True when |
|---|---|
-e path | Exists (any type) |
-f path | Exists and is a regular file |
-d path | Is a directory |
-L path | Is a symbolic link |
-s path | Exists and size is greater than zero |
-r / -w / -x | Readable / writable / executable by this user |
-p | Named pipe (FIFO) |
-S | Socket |
-b / -c | Block / character device |
-O / -G | Owned by the effective UID / GID |
-u / -g / -k | setuid / setgid / sticky bit set |
f1 -nt f2 | f1 is newer than f2 |
f1 -ot f2 | f1 is older than f2 |
f1 -ef f2 | Both are the same inode (hard links to one file) |
check_path() {
local p=$1
[[ -e $p ]] || { echo "does not exist: ${p}"; return 1; }
[[ -L $p ]] && echo "symlink → $(readlink -f "$p")"
[[ -d $p ]] && echo "directory"
[[ -f $p ]] && echo "regular file, $( [[ -s $p ]] && echo "non-empty" || echo "EMPTY" )"
[[ -r $p ]] && echo "readable"
[[ -w $p ]] && echo "writable"
[[ -x $p ]] && echo "executable"
}
Two details worth knowing:
-f follows symlinks. A symlink pointing at a regular file passes -f. To test the link itself, use -L. To find a broken symlink: [[ -L $p && ! -e $p ]].
-e is not -f. A directory passes -e and fails -f. Using -e where you meant -f is how a script tries to cat a directory.
The race nobody mentions:
if [[ -w $file ]]; then
echo data > "$file" # the file may have changed between the test and the write
fi
This is a TOCTOU (time-of-check to time-of-use) race. For anything security-relevant, attempt the operation and handle the failure rather than testing first:
if ! echo data > "$file" 2>/dev/null; then
echo "could not write ${file}" >&2
fi
Topic 5: case
For matching one value against several patterns, case beats an if/elif chain — it is faster, and the intent is obvious.
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart|reload) stop_service; start_service ;;
status) show_status ;;
"") echo "usage: $0 {start|stop|restart|status}" >&2; exit 2 ;;
*) echo "unknown command: $1" >&2; exit 2 ;;
esac
Patterns are globs, not regex:
| Pattern | Matches |
|---|---|
*.log | Anything ending in .log |
a|b|c | Any of the three |
[0-9]* | Starting with a digit |
[Yy]* | Starting with Y or y |
? | Exactly one character |
* | Anything — the catch-all default |
The terminators:
case $x in
a) echo "a" ;; # stop here — the normal case
b) echo "b" ;& # FALL THROUGH to the next block unconditionally (bash 4+)
c) echo "c" ;;& # continue TESTING subsequent patterns (bash 4+)
esac
;; is what you want almost always. ;& and ;;& exist and are rare enough that using them deserves a comment.
Validation with an allow-list:
case $environment in
dev|staging|production) ;; # valid — do nothing
*) echo "invalid environment: ${environment}" >&2; exit 2 ;;
esac
An empty body with a bare ;; is the idiom for “this is acceptable”. Allow-lists like this are the right way to validate input — a deny-list can always be worked around.
Try it yourself: Write a case that classifies a filename by extension, including a branch for files with no extension at all, and one for hidden files starting with ..
Common mistake: [ $x = "y" ] with $x unquoted and possibly empty. It produces “unary operator expected”, which names nothing useful about the actual problem. Quote it, or use [[ ]], which does not have the failure mode at all.