Conditionals and test
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 by | POSIX builtin (command) | Bash keyword |
| Word-splits / globs operands | Yes — must quote | No — quoting optional |
| String equality | = | = or == (glob on RHS) |
| Regex match | No | =~ |
| Combine conditions | -a / -o (deprecated) | && / || |
Works under dash / /bin/sh | Yes | No |
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
[ ] / 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.
- Leaving variables unquoted inside
[ ]—[ $x = yes ]becomes a syntax error when$xis 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/shscript — on Debian and Ubuntu/bin/shisdash, so the script works when run withbash file.shbut fails under cron orsh file.shwith "[[: not found". - Writing
[ "$a" < "$b" ]expecting a comparison — inside[ ]the<is a redirection that creates or reads a file named after$b; you need-ltfor 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 whenncan be0— it returns exit status1for a zero value, which aborts the script underset -eeven though nothing went wrong. - Chaining
cmd1 && cmd2 || cmd3as if it wereif/then/else— whencmd2itself fails,cmd3also runs, so the "else" fires after a successful test. - Using the deprecated
-aand-oto combine conditions inside[ ]— they make parsing ambiguous with operands that look like operators; chain separate[ ]calls with&&instead.
- 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 underdash. - Match the shebang to the syntax — write
#!/bin/bashwhen you use Bash features, and verify#!/bin/shscripts withdashorcheckbashismsso 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
casewhen a value has several discrete forms — it is faster and clearer than a chain ofeliftests and handles glob patterns natively. - Run
shellcheckon every script; it flags unquoted test operands,dashincompatibilities, and the&&...||pseudo-if mistake automatically.
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-splittingKnowledge Check
What does an if statement actually test in the shell?
- The exit status of the command after
if—0is treated as true, any non-zero value as false - Whether the command after
ifprinted 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
ifis 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/shisdash, 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
cmd2runs but fails, the||still firescmd3, so the "else" branch executes even thoughcmd1succeeded - The
&&and||operators do not consult any command's exit status at all, instead branching only on whatever text reaches stdout cmd3can never run because||binds more tightly than&&in the chain- The chain always exits with status
0, masking real failures fromset -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