test and [[ ]] - Conditionals in Shell Scripts

test and [[ ]] - Conditionals in Shell Scripts

What You'll Learn

  • The difference and trade-offs between test, [ ], and [[ ]]
  • How to pick the right operators for strings, numbers, and files
  • How to structurally avoid quoting bugs like [: too many arguments

Quick Summary

  • When in doubt, use [[ ]] (the safe default in bash)
  • Use [ ] (i.e. test) only when you need POSIX sh portability
  • Almost every bug comes from (1) missing quotes or (2) mixing up operators (-eq vs =)

Assumptions

  • Shell: bash ([[ ]] is a bash / ksh / zsh extension; it does not exist in POSIX sh)
  • Scripts start with #!/bin/bash

What's the difference between test, [ ], and [[]]?

Conclusion: [ ] is literally the test command. [[ ]] is a shell keyword with safer quoting, more operators, and pattern matching.

Here is the relationship in one table.

Syntax What it really is Portability Main strength
test EXPR A builtin (also exists on disk) POSIX (high) Most fundamental
[ EXPR ] An alias for test (] is an arg) POSIX (high) Reads well with if
[[ EXPR ]] A shell keyword bash etc. (limited) Quote-safe, =~, &&/|| allowed

Because [ ] is a command, you need spaces around [, ], and every operator. test and [ behave identically.

$ test 1 -lt 2; echo $?
0
$ [ 1 -lt 2 ]; echo $?
0

[[ ]], by contrast, is parsed as shell syntax, which is what makes it quote-safe and capable of pattern matching.

Confirm what [ really is

$ type [
[ is a shell builtin
$ ls -l /usr/bin/[
-rwxr-xr-x 1 root root ... /usr/bin/[

The builtin takes priority, but test/[ also exist as external commands for historical reasons.

How do you compare strings?

Conclusion: Use = for equality (== also works inside [[ ]]), -z for empty and -n for non-empty. Always double-quote variables.

Core operators

  • = ... equal (== is a synonym inside [[ ]])
  • != ... not equal
  • -z "$s" ... string is empty (zero length)
  • -n "$s" ... string is not empty
name="penguin"

if [[ "$name" = "penguin" ]]; then
    echo "match"
fi

if [[ -z "$name" ]]; then
    echo "empty"
else
    echo "not empty"
fi

= vs ==

== is a bash extension. For POSIX sh portability, use = inside [ ]. Inside [[ ]] both work.

[ "$a" = "$b" ]     # POSIX-safe
[[ "$a" == "$b" ]]  # bash (both = and == work inside [[ ]])

How do you compare numbers?

Conclusion: Use -eq -ne -lt -le -gt -ge for numbers. = and > are string operators and mean something different.

Operator Meaning Math symbol
-eq equal ==
-ne not equal !=
-lt less than <
-le less than or equal <=
-gt greater than >
-ge greater than or equal >=
count=42

if [[ "$count" -ge 10 ]]; then
    echo "10 or more"
fi

Do not mix up = and -eq

[[ "08" = "8" ]]    # false (different as strings)
[[ "08" -eq "8" ]]  # true (equal as numbers)

Using = where you mean a numeric compare leads to surprises with zero-padding or whitespace.

If you want arithmetic, (( )) lets you write math symbols directly: (( count >= 10 )).

How do you test whether a file exists or its type?

Conclusion: File test operators check existence, type, and permissions. The common ones are -e -f -d -r -w -x -s.

Operator Meaning
-e file exists (any type)
-f file is a regular file
-d file is a directory
-r file is readable
-w file is writable
-x file is executable
-s file size is greater than zero
-L file is a symbolic link
a -nt b a is newer than b
config="/etc/myapp.conf"

if [[ -f "$config" && -r "$config" ]]; then
    echo "readable config file exists"
else
    echo "config missing or unreadable"
fi

Test for "does not exist"

Negate with !. A common early-return pattern.

if [[ ! -d "$dir" ]]; then
    echo "No such directory: $dir" >&2
    exit 1
fi

Why is [[]] less error-prone?

Conclusion: Inside [[ ]], expanded variables are not subject to word splitting or globbing, so a missing quote does not break the syntax.

Since [ ] is a command, an empty or whitespace-containing variable changes the argument count and breaks the expression.

v=""
[ $v = "x" ]      # becomes [ = "x" ]: too many arguments / syntax error
[ "$v" = "x" ]    # becomes [ "" = "x" ] (correctly false) - quoting saves you

v="a b"
[ $v = "x" ]      # becomes [ a b = "x" ] (4 args, error)

Inside [[ ]], an expanded variable is not split or globbed, so even a missing quote rarely breaks anything.

v="a b"
[[ $v = "x" ]]    # does not break (false)

Still, keep the habit of quoting. Writing "$var" in both [ ] and [[ ]] keeps the code safe no matter which you port it to.

What are [[]]'s pattern matching and regex?

Conclusion: Inside [[ ]], the right side of == is a glob pattern and =~ is a regular expression. Neither exists in [ ].

Glob pattern matching (==)

Leave the right side unquoted for a pattern; quote it to match it as a literal string.

file="report.txt"

if [[ "$file" == *.txt ]]; then
    echo "text file"
fi

if [[ "$file" == "*.txt" ]]; then
    echo "only matches the literal string *.txt"
fi

Regex matching (=~)

Leave the right side unquoted. Captured groups land in the BASH_REMATCH array.

input="port=8080"

if [[ "$input" =~ ^port=([0-9]+)$ ]]; then
    echo "port number: ${BASH_REMATCH[1]}"
fi

How do you combine multiple conditions?

Conclusion: Inside [[ ]] use && and ||. With [ ], join separate commands: [ ... ] && [ ... ].

# Combine inside [[ ]] (readable)
if [[ "$age" -ge 18 && "$age" -lt 65 ]]; then
    echo "working age"
fi

# Join [ ] commands with &&
if [ "$age" -ge 18 ] && [ "$age" -lt 65 ]; then
    echo "working age"
fi

The -a (AND) and -o (OR) operators inside [ ] are deprecated. Their argument parsing is ambiguous and a frequent source of bugs. To combine conditions, split into separate [ ] and join with && / ||.

Summary: when to use test vs [[]]

Conclusion: In bash scripts, default to [[ ]] and reserve [ ] for places that need POSIX sh. Get quoting and operator choice right and bugs nearly disappear.

  • [[ ]] ... bash only. Quote-safe, supports =~, &&/||, and reads well
  • [ ] / test ... when you need #!/bin/sh or POSIX compatibility
  • Strings use = / -z / -n, numbers use the -eq family, files use -f / -d
  • Always quote variables as "$var"

Copy-paste template

# Argument check
if [[ $# -lt 1 ]]; then
    echo "usage: $0 <file>" >&2
    exit 1
fi

target="$1"

# File existence and type
if [[ ! -f "$target" ]]; then
    echo "not a regular file: $target" >&2
    exit 1
fi

# Branch on extension
if [[ "$target" == *.log ]]; then
    echo "processing a log file"
fi

Next Reading