Bash & Shell Scripting for Engineers

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.

KEY CONCEPT

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.

WARNING

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.

KEY CONCEPT

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:

  1. Use set -u (covered in detail in Module 4). Errors on unset variables.
  2. Use ${var:-default} to be explicit:
if [[ "${config:-dev}" == "prod" ]]; then ...
  1. 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.

PRO TIP

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

KNOWLEDGE CHECK

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/-lt inside [[ ]].
  • 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 $re for readability.
  • case ... in for many branches. Cleaner than nested if/elif.
  • Always use $var:-default or set -u to catch unset-variable bugs.
  • Never use > for version comparison — use sort -V or a real version comparator.

Next lesson: loops — for vs while, read -r, and how to iterate over files without getting burned by spaces.