Conditionals and test
Topic 60

Conditionals and test

Shell Scripting

A conditional in the shell does not test "true" or "false" the way most languages do — it runs a command and branches on that command's exit status. Exit code 0 means success and is treated as true; any non-zero code is false. if cmd; then ...; fi simply runs cmd and looks at $?. The thing people call "the test" — [ ... ], its synonym test, and Bash's [[ ... ]] — is just a command whose whole job is to evaluate an expression and exit 0 or 1.

That detail decides how scripts behave under failure. Because [ is an ordinary command, every operand is a word the shell expands first, so an unquoted empty variable silently vanishes and turns a valid-looking test into a syntax error or a wrong answer. On Debian and Ubuntu the default /bin/sh is dash, not Bash, so [[ ]] and == are unavailable in #!/bin/sh scripts — getting the shebang wrong is a common cause of "works on my laptop, breaks in cron".

Exit Status as the Boolean

There is no boolean type in the shell. The condition after if, while, or && is a command, and its exit status is the decision. This is why you write if grep -q root /etc/passwd; then directly rather than capturing output and comparing it — grep already returns 0 when it matches and 1 when it does not. Wrapping such a command in [ "$(...)" ] is redundant and slower.

# Branch on a command's exit status, not its output
if systemctl is-active --quiet nginx; then
    echo "nginx is running"
elif systemctl is-failed --quiet nginx; then
    echo "nginx failed"
else
    echo "nginx is stopped"
fi

The same logic powers the short-circuit operators. cmd1 && cmd2 runs cmd2 only if cmd1 succeeded; cmd1 || cmd2 runs cmd2 only if cmd1 failed. They read cleanly for one-liners — mkdir -p /opt/app && cd /opt/app — but they are not a substitute for if when the "then" branch itself can fail, because the exit status of the whole chain becomes that of the last command run.

test, [ and [[

test EXPR and [ EXPR ] are the same POSIX builtin; the [ form just requires a closing ] as its final argument, which is why the space before ] is mandatory. They support file tests (-f, -d, -e, -r, -x), string tests (-z for empty, -n for non-empty, = and !=), and numeric tests with worded operators (-eq, -ne, -lt, -gt, -le, -ge). The numeric operators are words on purpose: < and > inside [ ] are redirections, not comparisons.

[[ EXPR ]] is a Bash keyword, not a command, so the shell parses it as a unit and does not word-split or glob-expand its operands. That removes the entire class of "unquoted empty variable" bugs, adds =~ for regex matching and </> for lexical string comparison, and lets == do glob pattern matching on the right-hand side. The cost is portability: [[ ]] exists in Bash, ksh, and zsh but not in dash, so a #!/bin/sh script that uses it fails the moment Debian or Ubuntu runs it under dash.

Capability[ ] / test[[ ]]
Defined byPOSIX builtin (command)Bash keyword
Word-splits / globs operandsYes — must quoteNo — quoting optional
String equality== or == (glob on RHS)
Regex matchNo=~
Combine conditions-a / -o (deprecated)&& / ||
Works under dash / /bin/shYesNo

Quoting and Empty Operands

Inside [ ] the shell expands variables before test ever sees them, so an empty or unset variable disappears entirely. [ $x = yes ] with x empty becomes [ = yes ], which is a syntax error; with x="a b" it becomes [ a b = yes ], too many arguments. Quoting every operand — [ "$x" = yes ] — collapses the expansion to a single argument and the test behaves. This is the single most common defect in shell scripts that pass casual testing and break on the first odd input.

# Unquoted: breaks when $1 is empty or contains spaces
if [ -z $1 ]; then echo "missing arg"; fi    # wrong

# Quoted: correct under [ ]
if [ -z "$1" ]; then echo "missing arg"; fi

# [[ ]] does not word-split, so this is safe even unquoted (Bash only)
if [[ -z $1 ]]; then echo "missing arg"; fi

With set -u in effect — which any careful script enables — an unset variable is a hard error before the test even runs, so [ -z "$1" ] aborts when $1 was never passed. Guard it with a default: [ -z "${1:-}" ]. The old defensive trick of writing [ "x$x" = "xyes" ] predates reliable quoting and is no longer needed; quote the variable instead.

Arithmetic and Pattern Matching

For numeric work, (( EXPR )) evaluates a C-style arithmetic expression and sets exit status from the result — non-zero value is true, zero is false. if (( count > 5 )) reads naturally and uses real >, <, ==, and % operators instead of -gt and friends. It is Bash-only, like [[ ]]; under dash use [ "$count" -gt 5 ]. A subtle trap: (( count )) where count is 0 returns exit status 1, so set -e scripts can exit unexpectedly on a legitimate zero.

For string matching, [[ $file == *.log ]] does glob matching on the unquoted right-hand side, and [[ $ip =~ ^[0-9]+\.[0-9]+ ]] matches a POSIX extended regex, exposing capture groups in BASH_REMATCH. The regex pattern must be unquoted or stored in a variable; quoting it turns the match into a literal string comparison. When a value can take several discrete forms, case is clearer than a stack of elif branches, because each glob pattern is matched in order and the first match wins.

# case branches on glob patterns, first match wins
case "$1" in
    start|restart)  systemctl restart myapp ;;
    stop)           systemctl stop myapp ;;
    *.conf)         echo "got a config file: $1" ;;
    *)              echo "usage: $0 {start|stop|restart}"; exit 2 ;;
esac
[ ] vs [[ ]]

[ ] / test — the POSIX builtin. It runs everywhere, including dash and #!/bin/sh, but its operands are word-split and glob-expanded, so every variable must be quoted and a single empty unquoted variable can break the test. Choose it when the script must be portable across shells.

[[ ]] — the Bash keyword. It parses operands without word-splitting or globbing (quoting is optional), adds =~ regex matching and &&/|| grouping, but exists only in Bash, ksh, and zsh. Choose it as the default in any #!/bin/bash script for its safer handling of empty and space-containing values.

Reach for [ ] only when portability to dash forces it; otherwise [[ ]] removes the most common quoting bug for free.

Common Mistakes
  • Leaving variables unquoted inside [ ][ $x = yes ] becomes a syntax error when $x is empty and a "too many arguments" error when it contains spaces, both of which slip past testing with simple inputs.
  • Using [[ ]], ==, or (( )) in a #!/bin/sh script — on Debian and Ubuntu /bin/sh is dash, so the script works when run with bash file.sh but fails under cron or sh file.sh with "[[: not found".
  • Writing [ "$a" < "$b" ] expecting a comparison — inside [ ] the < is a redirection that creates or reads a file named after $b; you need -lt for numbers or [[ ]] for strings.
  • Quoting the regex in [[ $x =~ "$pat" ]] — quoting forces a literal string match instead of a regex, so the anchors and character classes are treated as ordinary characters and the match silently fails.
  • Relying on (( n )) as a truth test when n can be 0 — it returns exit status 1 for a zero value, which aborts the script under set -e even though nothing went wrong.
  • Chaining cmd1 && cmd2 || cmd3 as if it were if/then/else — when cmd2 itself fails, cmd3 also runs, so the "else" fires after a successful test.
  • Using the deprecated -a and -o to combine conditions inside [ ] — they make parsing ambiguous with operands that look like operators; chain separate [ ] calls with && instead.
Best Practices
  • Quote every variable inside [ ][ -z "$var" ], never [ -z $var ] — so empty and space-containing values stay a single argument.
  • Prefer [[ ]] over [ ] in Bash scripts for its safer parsing, =~ regex, and &&/|| grouping; reserve plain [ ] for scripts that must run under dash.
  • Match the shebang to the syntax — write #!/bin/bash when you use Bash features, and verify #!/bin/sh scripts with dash or checkbashisms so dash-incompatible code is caught before deploy.
  • Branch directly on a command — if grep -q PATTERN file — rather than capturing output into a variable and testing it with [ ].
  • Use (( )) for numeric comparisons in Bash and [ "$n" -gt 5 ] in POSIX scripts; never compare numbers with the string operator =.
  • Reach for case when a value has several discrete forms — it is faster and clearer than a chain of elif tests and handles glob patterns natively.
  • Run shellcheck on every script; it flags unquoted test operands, dash incompatibilities, and the &&...|| pseudo-if mistake automatically.
Comparable toolsPOSIX sh / dash — the portable subset: test and [ ] only, no [[ ]], (( )), or =~PowerShell — Windows shell with real boolean operators (-eq, -match) and typed comparisons instead of exit-status truthzsh — supports [[ ]] and (( )) like Bash, with extra pattern-matching operators and stricter default word-splitting

Knowledge Check

What does an if statement actually test in the shell?

  • The exit status of the command after if0 is treated as true, any non-zero value as false
  • Whether the command after if printed any non-empty output to its standard-output stream
  • Whether a boolean expression placed inside the mandatory [ ] test brackets evaluates to true
  • Whether the variable named after if is set to the string "true"

A script using [[ -n $x ]] works when run as bash deploy.sh but fails from cron with "[[: not found". Why?

  • The shebang is #!/bin/sh, and on Debian/Ubuntu /bin/sh is dash, which has no [[ ]] keyword
  • Cron sanitizes scripts by stripping Bash-only keywords for security reasons
  • [[ ]] requires the tested variable to be exported into the environment, which cron does not do
  • The script is missing execute permission, so the kernel falls back to a limited shell

Why must you quote the variable in [ "$x" = yes ] but quoting is optional in [[ $x == yes ]]?

  • [ is a command whose operands are word-split and glob-expanded first, while [[ ]] is a keyword the shell parses as a single unit
  • [ ] only accepts bare string literals, so any variable reference must be wrapped in double quotes before it can be recognized as an operand
  • Quoting silently changes = from a numeric comparison into a string comparison when used inside [ ]
  • [[ ]] automatically initializes unset variables to the empty string, so quoting is never needed

Why is cmd1 && cmd2 || cmd3 an unreliable substitute for if cmd1; then cmd2; else cmd3; fi?

  • If cmd2 runs but fails, the || still fires cmd3, so the "else" branch executes even though cmd1 succeeded
  • The && and || operators do not consult any command's exit status at all, instead branching only on whatever text reaches stdout
  • cmd3 can never run because || binds more tightly than && in the chain
  • The chain always exits with status 0, masking real failures from set -e

How should you compare two integers a and b in a portable #!/bin/sh script?

  • [ "$a" -gt "$b" ], using the worded numeric operator -gt
  • (( a > b )), the C-style arithmetic form
  • [[ $a > $b ]], which compares numerically inside double brackets
  • [ "$a" > "$b" ], using the > comparison operator

You got correct