Conditionals, Tests & Pattern Matching

Why `if` tests an exit status rather than a boolean, the real differences between `[ ]` and `[[ ]]`, and every file test operator worth knowing.

beginner 17 min lesson hands-on task included

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 isA command (/usr/bin/[, also a builtin)A shell keywordArithmetic evaluation
PortablePOSIX — works in shbash/ksh/zsh onlybash/ksh/zsh only
Unquoted variablesDangerousSafeSafe
&& / || insideNo — use -a / -oYesYes
< >Redirection! Must escapeString comparisonNumeric comparison
Regex =~NoYesNo
Globbing on the rightNoYes

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 [[ ]]:

OperatorMeaning
-eq / -neequal / not equal
-gt / -gegreater / greater-or-equal
-lt / -leless / 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:

OperatorMeaning
= or ==Equal. In [[ ]] the right side is a glob pattern unless quoted
!=Not equal
< / >Lexicographic. Must be escaped in [ ] or it redirects
-zZero length (empty)
-nNon-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

TestTrue when
-e pathExists (any type)
-f pathExists and is a regular file
-d pathIs a directory
-L pathIs a symbolic link
-s pathExists and size is greater than zero
-r / -w / -xReadable / writable / executable by this user
-pNamed pipe (FIFO)
-SSocket
-b / -cBlock / character device
-O / -GOwned by the effective UID / GID
-u / -g / -ksetuid / setgid / sticky bit set
f1 -nt f2f1 is newer than f2
f1 -ot f2f1 is older than f2
f1 -ef f2Both 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:

PatternMatches
*.logAnything ending in .log
a|b|cAny 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.