Test Expressions ([ ] and [[ ]])

Every if statement, while loop, and conditional check in Bash relies on a test expression to decide which branch to take. Test expressions are how you ask questions like "does this file exist?", "is this number greater than 10?", or "are these two strings equal?". Bash gives you two ways to write them: the classic [ ] (a thin wrapper around the test command) and the Bash-specific [[ ]], which is safer and more powerful. Understanding both — and knowing which to reach for — is essential to writing scripts that behave correctly.

Overview / How it works

In Bash, conditionals like if and while don’t evaluate a boolean value the way other languages do. Instead, they run a command and check its exit status: 0 means success (true), and any non-zero value means failure (false). test is a real command — you can run test -f /etc/hosts directly, and it exits 0 if the file exists or 1 if it doesn’t. It prints nothing; its only output is its exit status.

[ is simply another name for the same test command — on most systems it exists both as a shell builtin and as a real executable at /usr/bin/[. Because [ is a command, the closing ] is actually its last argument, not special syntax. That’s why [ -f /etc/hosts ] requires a space before the closing bracket: the shell needs to see ] as its own word, exactly like any other argument. Since [ is an ordinary command, its arguments go through normal word splitting and pathname expansion (globbing) before test ever sees them — which is the root cause of most [ ] bugs.

[[ ]] is different: it’s a Bash keyword (a piece of shell grammar), not a command. Because the shell parses it specially, variables inside [[ ]] are not word-split or glob-expanded even when unquoted, &&/|| work directly inside it, and it supports glob and regex pattern matching that [ ] can’t do safely. The tradeoff is that [[ ]] is Bash-only — it won’t work in a POSIX sh or dash script, while [ ]/test works everywhere. Since this course targets Bash specifically, [[ ]] is the recommended default for new scripts; you’ll still see [ ] constantly in real-world code and need to read it fluently.

Syntax

test EXPRESSION
[ EXPRESSION ]
[[ EXPRESSION ]]

All three forms evaluate EXPRESSION and set the exit status accordingly. The table below lists the operators you’ll use most.

Category Operator Meaning
String = or == strings are equal (== only inside [[ ]])
String != strings are not equal
String -z string is empty (zero length)
String -n string is not empty
String < / > lexicographic less-than / greater-than ([[ ]] only; must be escaped as \</\> in [ ])
Numeric -eq / -ne equal / not equal
Numeric -lt / -le less than / less than or equal
Numeric -gt / -ge greater than / greater than or equal
File -e path exists (any type)
File -f path exists and is a regular file
File -d path exists and is a directory
File -r / -w / -x path is readable / writable / executable
File -s path exists and has a size greater than zero
Logical ! negates the result
Logical && / || AND / OR (inside [[ ]]; use -a/-o or separate [ ] tests joined by shell &&/|| for [ ])

Examples

Example 1: checking whether a directory exists before writing a backup into it.

#!/usr/bin/env bash
BACKUP_DIR="/var/backups/website"

if [ -d "$BACKUP_DIR" ]; then
    echo "Backup directory exists, proceeding."
else
    echo "Creating backup directory..."
    mkdir -p "$BACKUP_DIR"
fi

Output:

Creating backup directory...

The script tests the -d (directory) condition. Because /var/backups/website didn’t exist yet, [ -d "$BACKUP_DIR" ] exited non-zero, so the else branch ran and created it with mkdir -p.

Example 2: a numeric comparison that classifies disk usage into severity levels.

#!/usr/bin/env bash
USAGE=87

if [ "$USAGE" -ge 90 ]; then
    echo "CRITICAL: disk usage at ${USAGE}%"
elif [ "$USAGE" -ge 80 ]; then
    echo "WARNING: disk usage at ${USAGE}%"
else
    echo "OK: disk usage at ${USAGE}%"
fi

Output:

WARNING: disk usage at 87%

-ge compares the two values as integers, not as text, so 87 -ge 90 is false but 87 -ge 80 is true. Using = or == here instead of -ge would be a mistake — those operators compare text, not magnitude.

Example 3: using [[ ]] for string comparison and pattern matching, something plain [ ] cannot do safely.

#!/usr/bin/env bash
read -rp "Enter environment (dev/staging/prod): " ENV

if [[ "$ENV" == "prod" || "$ENV" == "staging" ]]; then
    echo "Deploying to a shared environment: $ENV"
elif [[ "$ENV" == dev* ]]; then
    echo "Deploying to a local dev environment: $ENV"
else
    echo "Unknown environment: $ENV"
fi

Output (when the user types prod):

Deploying to a shared environment: prod

Inside [[ ]], dev* is treated as a glob pattern (matching any string starting with dev) rather than a literal filename, because the right-hand side of == in [[ ]] is not word-split or glob-expanded against the filesystem. The same pattern used unquoted inside [ ] would behave unpredictably.

How it works step by step

When Bash hits if [ -d "$BACKUP_DIR" ]; then, it does the following:

  1. Bash expands "$BACKUP_DIR" into its current value, keeping it as a single word because it’s quoted.
  2. Bash runs the command [ with the arguments -d, the expanded path, and ].
  3. test (invoked as [) checks the filesystem, sets its exit status to 0 or 1, and produces no output.
  4. if inspects that exit status: 0 runs the then block, anything else moves to elif/else.

With [[ ]] the process is similar, except step 2 never happens as a separate command invocation — the shell’s parser evaluates the expression directly as part of the if statement’s grammar, which is why quoting matters less (though it’s still good practice) and why patterns and regexes can be handled specially.

Common Mistakes

Mistake 1: missing the required spaces around the brackets. [ and ] must be surrounded by whitespace because they are separate words/arguments, not punctuation.

[$a -eq $b]

This is parsed as a command literally named [$a, which doesn’t exist, so Bash reports "command not found". The fix is to always leave a space after [ and before ]:

[ "$a" -eq "$b" ]

Mistake 2: leaving a variable unquoted inside [ ]. If the variable is empty or contains spaces, word splitting changes how many arguments test receives.

FILE_NAME="my report.txt"
if [ $FILE_NAME = "my report.txt" ]; then
    echo "Match"
fi

Because $FILE_NAME is unquoted, it splits into two words (my and report.txt), and [ receives too many arguments, producing an error instead of a comparison. Quoting fixes it:

FILE_NAME="my report.txt"
if [ "$FILE_NAME" = "my report.txt" ]; then
    echo "Match"
fi

Mistake 3: using a numeric operator on strings, or a string operator on numbers. -eq tries to parse both sides as integers.

ROLE="admin"
if [ "$ROLE" -eq "admin" ]; then
    echo "Is admin"
fi

This fails with integer expression expected, because -eq can’t parse the text admin as a number. The correct operator for string equality is = (or == inside [[ ]]):

ROLE="admin"
if [ "$ROLE" = "admin" ]; then
    echo "Is admin"
fi

Best Practices

  • Prefer [[ ]] in Bash scripts — it avoids word-splitting and glob surprises and supports pattern matching and &&/|| directly.
  • Always quote variable expansions inside [ ] (and as a habit inside [[ ]] too), e.g. [ -f "$path" ], not [ -f $path ].
  • Use -eq/-ne/-lt/-le/-gt/-ge only for integers; use =/!= for strings.
  • Leave a space immediately after [/[[ and immediately before ]/]].
  • Test the specific file type you actually need (-f for a file, -d for a directory) rather than the generic -e, so you don’t accidentally act on the wrong kind of path.
  • If a script needs to run under plain POSIX sh as well as Bash, stick to [ ]/test and avoid [[ ]], since [[ ]] is a Bash (and some other shells’) extension, not POSIX.
  • Check help test or man test when unsure exactly what an operator does — don’t guess.

Practice Exercises

  • Write a script that takes a path as $1 and prints whether it’s a regular file, a directory, or doesn’t exist at all, using -f, -d, and -e.
  • Write a script with a variable ATTEMPTS=2 that prints retry if ATTEMPTS is less than 3, and give up otherwise.
  • Write a script using [[ ]] that checks whether a variable FILENAME ends in .log using a glob pattern (hint: [[ "$FILENAME" == *.log ]]), and prints a different message if it doesn’t.

Summary

  • [ ] is the test command; the closing ] is its final argument, so spacing and quoting matter a lot.
  • [[ ]] is a Bash keyword: safer with unquoted variables, and it supports pattern matching and &&/|| directly, but it’s Bash-only, not POSIX.
  • Use =/!= (or == in [[ ]]) for strings, and -eq/-ne/-lt/-le/-gt/-ge for integers — mixing them up produces errors or wrong results.
  • File test operators like -e, -f, and -d let you check a path’s existence and type before acting on it.
  • Always quote variable expansions inside test expressions to avoid word-splitting bugs, especially with [ ].