Conditionals Done Right
Bash has three ways to write conditionals: [[ ... ]], [ ... ], and test .... They look similar. One is modern and safe; the other two are historical and error-prone. If you write [[ ... ]] for every new script, you avoid a whole category of bugs.
This lesson is about picking the right conditional, writing it safely, and the specific edge cases (empty strings, unset variables, numeric vs string comparison) where conditionals trip people up.
Use [[ ... ]] for all conditionals. Every time. The only reason to use [ ... ] or test is portability to non-Bash shells like dash — which is the only case where [[ isn't available.
The three forms
# 1. Double brackets — Bash extension (RECOMMENDED)
if [[ -f "$file" ]]; then
echo "file exists"
fi
# 2. Single brackets — POSIX test builtin
if [ -f "$file" ]; then
echo "file exists"
fi
# 3. test — same as single brackets, different spelling
if test -f "$file"; then
echo "file exists"
fi
All three can do similar things. [[ adds:
- No word splitting on unquoted variables inside.
- Pattern matching with
==and glob patterns. - Regex matching with
=~. - Logical operators
&&,||,!directly (not-a,-o,!). - Safer empty-string handling.
Why [[ is safer — the unquoted-variable trap
var=""
if [ $var = "hello" ]; then ...
# ERROR: "unary operator expected"
# Because $var expands to nothing, you get: [ = "hello" ], which is a syntax error.
if [[ $var = "hello" ]]; then ...
# Works. False. Because [[ treats unquoted $var as an empty string.
[[ does not word-split unquoted variables. [ does — because [ is actually a command, and its arguments go through normal shell word splitting.
Another example:
file="my report.txt"
if [ -f $file ]; then ...
# ERROR: "too many arguments"
# Because the space in $file makes it two arguments: [ -f my report.txt ]
if [[ -f $file ]]; then ...
# Works. Checks for the file "my report.txt".
Both cases: [[ handles it. [ needs careful quoting or it breaks.
Even with [[, you should still quote user-controlled values by habit. But the cost of forgetting is a correct-but-slightly-suboptimal script, not a runtime error.
The comparison operators
String comparison
[[ "$a" == "$b" ]] # equal
[[ "$a" != "$b" ]] # not equal
[[ "$a" < "$b" ]] # lexicographic less than (inside [[ only)
[[ "$a" > "$b" ]] # lexicographic greater than (inside [[ only)
[[ -z "$a" ]] # empty (zero-length)
[[ -n "$a" ]] # non-empty
Note: inside [[, < and > are NOT redirection — they're string comparison. Inside [, they are redirection, which is why [ can't do lexicographic comparison safely.
Numeric comparison
For integers, use -eq, -ne, -lt, -le, -gt, -ge:
[[ $count -gt 10 ]]
[[ $age -ge 18 ]]
[[ $n -eq 0 ]]
Or use (( ... )) which is often cleaner:
(( count > 10 ))
(( age >= 18 ))
(( n == 0 ))
Inside (( )), variables don't need $, and comparisons use the C-style operators (==, <, >, <=, >=). This is almost always the right choice for numeric conditions.
For numbers use (( )). For strings use [[ ]]. Mixing them is where engineers get tripped up — e.g. using == on numbers inside [[ ]] works only because Bash is doing string comparison, which usually gives the right answer for integer strings.
File tests
[[ -e "$f" ]] # exists (any type)
[[ -f "$f" ]] # exists and is a regular file
[[ -d "$f" ]] # exists and is a directory
[[ -L "$f" ]] # is a symlink
[[ -r "$f" ]] # readable
[[ -w "$f" ]] # writable
[[ -x "$f" ]] # executable
[[ -s "$f" ]] # exists and is non-empty
[[ "$f1" -nt "$f2" ]] # f1 newer than f2
[[ "$f1" -ot "$f2" ]] # f1 older than f2
Pattern matching
Inside [[, == with a glob on the right matches:
[[ "$file" == *.txt ]] # glob match — true if file ends in .txt
[[ "$name" == prefix_* ]] # true if name starts with prefix_
[[ "$host" == *.example.com ]]
Note: the right-hand side is a glob, NOT a regex. No ^, $, \d, etc.
Important: quote the variable but NOT the pattern. Quoting the pattern makes it a literal string.
[[ "$file" == "*.txt" ]] # literal match — looks for a file LITERALLY named "*.txt"
[[ "$file" == *.txt ]] # glob match — what you probably want
Regex matching
Inside [[, =~ does regex matching:
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "looks like an IPv4 address"
fi
# Captured groups are in BASH_REMATCH
if [[ "$version" =~ v([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
fi
Quote-rules for regex: put the regex in a variable to avoid quoting confusion:
re='^[0-9]+$'
if [[ "$input" =~ $re ]]; then ...
Quoting the regex on the right-hand side of =~ makes it a literal match, not a regex. Unquoted or in a variable.
Logical operators
Inside [[:
[[ $a == 1 && $b == 2 ]] # AND
[[ $a == 1 || $b == 2 ]] # OR
[[ ! -f "$file" ]] # NOT
Inside [, use -a, -o, ! (or chain with &&/|| between multiple [ commands). Don't. Use [[.
# [[ version — clean
if [[ -f "$f" && -r "$f" ]]; then ...
# [ version — works but uglier
if [ -f "$f" ] && [ -r "$f" ]; then ...
# [ -a version — DON'T, easy to get wrong
if [ -f "$f" -a -r "$f" ]; then ...
The unset-variable trap
Without set -u, referencing an unset variable silently expands to empty:
unset config
if [[ "$config" == "prod" ]]; then ...
# Expands to: if [[ "" == "prod" ]] — just false, no error
If config was supposed to be set and wasn't, you silently take the wrong branch. Defenses:
- Use
set -u(covered in detail in Module 4). Errors on unset variables. - Use
${var:-default}to be explicit:
if [[ "${config:-dev}" == "prod" ]]; then ...
- Use
${var:?}to fail early if it should be set:
: "${config:?must be set}"
if [[ "$config" == "prod" ]]; then ...
The (( )) arithmetic conditional
For numeric comparisons, this is usually cleaner than [[ ]]:
count=5
# These do the same thing
if [[ $count -gt 0 ]]; then ...
if (( count > 0 )); then ...
# But (( )) reads more like code
if (( count > 0 && count < 100 )); then ...
(( )) also has a special convention: 0 is true (success) and non-zero is false (failure). Wait, that's backwards — it is, compared to Bash's general "exit code 0 = success" — but it's consistent with C:
if (( count )); then # true when count != 0
echo "we have some"
fi
if (( 5 + 3 )); then # true, result is 8 (non-zero)
echo "truthy"
fi
Use (( var )) as a shorthand for "var is not zero."
Ternary — the [[ cond ]] && A || B pattern
Bash does not have a true ternary, but you see this pattern a lot:
[[ $count -gt 10 ]] && echo "big" || echo "small"
Warning: this is subtly broken. If echo "big" fails (exit non-zero), the || branch runs too. For trivial cases like echo it's fine, but be careful:
# Broken — both branches may run if the first fails
[[ $count -gt 10 ]] && increment_counter || reset_counter
For real branching, use if:
if [[ $count -gt 10 ]]; then
increment_counter
else
reset_counter
fi
case — when you have many branches
case is the cleaner choice for "dispatching on a string":
case "$action" in
start)
echo "starting"
;;
stop|quit|exit)
echo "stopping"
;;
restart)
stop
start
;;
*.log)
echo "got a log file"
;;
*)
echo "unknown: $action"
exit 1
;;
esac
Patterns use glob syntax, multiple patterns are separated by |, and *) is the default case.
Use case once you have 3 or more branches of a string check. It is more readable and has nicer syntax for "these values share a branch" (foo|bar|baz)).
Common conditional mistakes
Mistake 1: forgetting spaces around the brackets
if [$x -eq 0]; then # ERROR
if [ $x -eq 0 ]; then # correct for [
if [[ $x -eq 0 ]]; then # correct for [[
[ is a command. You need spaces around it because the shell has to recognize it as a word boundary.
Mistake 2: using -eq for string comparison
if [[ "$a" -eq "$b" ]]; then ... # treats both as numbers
if [[ "$a" == "$b" ]]; then ... # string comparison
-eq is numeric. Using it on strings either works (if they're integers) or fails with a complaint about integer expression.
Mistake 3: quoting the regex
if [[ "$x" =~ "^[0-9]+$" ]]; then ... # literal match
if [[ "$x" =~ ^[0-9]+$ ]]; then ... # regex match
# or
re='^[0-9]+$'
if [[ "$x" =~ $re ]]; then ... # regex match
Mistake 4: trusting string comparison for numeric data
if [[ "$count" == "10" ]]; then # works for "10" but FAILS for "010" or "10 " (whitespace)
if (( count == 10 )); then # numeric — tolerates leading zeros, whitespace
Mistake 5: comparing versions as strings
if [[ "$version" > "1.10" ]]; then ... # TRUE for "1.9" because "1" < "1" lexicographically
For real version comparison, use sort -V:
if [[ "$(printf '%s\n' "$version" "1.10" | sort -V | head -1)" == "1.10" ]]; then
# version >= 1.10
...
fi
Or just: convert versions to comparable integers, or use a tool.
A worked example — input validation
validate_input() {
local name="$1"
local age="$2"
local email="$3"
# Name: non-empty, alphanumeric + spaces
if [[ -z "$name" ]]; then
echo "error: name is required" >&2
return 1
fi
if [[ ! "$name" =~ ^[A-Za-z\ ]+$ ]]; then
echo "error: name must be alphabetic" >&2
return 1
fi
# Age: positive integer, realistic range
if [[ ! "$age" =~ ^[0-9]+$ ]]; then
echo "error: age must be a non-negative integer" >&2
return 1
fi
if (( age < 0 || age > 150 )); then
echo "error: age out of range" >&2
return 1
fi
# Email: simple pattern
if [[ ! "$email" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then
echo "error: invalid email" >&2
return 1
fi
return 0
}
validate_input "$@" || exit 1
Clean, readable, uses [[ everywhere and (( )) for numeric ranges. Quoted inputs even though [[ is tolerant. Clear error messages.
Quiz
You have COUNT=0 and want to check if COUNT is zero. Which conditional is the most robust?
What to take away
- Use
[[ ... ]]for all string and file conditionals. Always. Everywhere. - Use
(( ... ))for numeric conditionals. Cleaner than-gt/-ltinside[[ ]]. - Inside
[[, unquoted variables do not word-split — but quoting never hurts. - Pattern matching:
[[ $f == *.txt ]]— right side is a glob, not a regex. - Regex matching:
[[ $x =~ regex ]]— don't quote the regex; use$refor readability. case ... infor many branches. Cleaner than nestedif/elif.- Always use
$var:-defaultorset -uto catch unset-variable bugs. - Never use
>for version comparison — usesort -Vor a real version comparator.
Next lesson: loops — for vs while, read -r, and how to iterate over files without getting burned by spaces.