opscape

Bash scripting guide · part 13 of 32

Test operators

Every file, string and number test for [ ] and [[ ]], pattern and regex matching, and the traps like [ $a > $b ] creating a file.

test EXPRESSION, [ EXPRESSION ] and [[ EXPRESSION ]] evaluate a condition and exit 0 for true, 1 for false, and 2 when the expression itself is malformed. They share most operators. The differences are in the parsing: [ is an ordinary command, so its arguments go through word splitting and globbing first; [[ is part of bash's grammar and sees the expression before any of that happens. In bash scripts, prefer [[ ]] and keep [ ] for POSIX sh. The test operators cheat sheet lists every operator on one page.

File tests

Test True when the path
-e exists (any type)
-f is a regular file
-d is a directory
-L or -h is a symbolic link
-r -w -x is readable, writable, executable by you
-s exists and is not empty
-p -S -b -c is a named pipe, socket, block or character device
a -nt b, a -ot b is newer / older than b (modification time)
a -ef b is the same file as b (same device and inode)

All tests except -L follow symbolic links, so a link to a file passes -f too.

Check what a path is

for p in notes.txt data link-to-notes scripts/greet.sh missing; do
  printf '%-17s' "$p"
  [[ -e $p ]] && printf ' exists' || printf ' missing'
  [[ -f $p ]] && printf ' file'
  [[ -d $p ]] && printf ' dir'
  [[ -L $p ]] && printf ' symlink'
  [[ -x $p && -f $p ]] && printf ' executable'
  echo
done
output
notes.txt         exists file
data              exists dir
link-to-notes     exists file symlink
scripts/greet.sh  exists file executable
missing           missing

link-to-notes is reported as both a file and a symlink, because -f follows the link to notes.txt. Directories are also -x (searchable), hence the extra -f check for "executable file".

A broken symlink does not exist, but is a link

ln -s nowhere.txt broken
[[ -e broken ]] || echo "-e: broken does not exist"
[[ -L broken ]] && echo "-L: but it is a symlink"
[[ -s data/users.csv ]] && echo "-s: users.csv is not empty"
touch empty.txt; [[ -s empty.txt ]] || echo "-s: empty.txt is empty"
output
-e: broken does not exist
-L: but it is a symlink
-s: users.csv is not empty
-s: empty.txt is empty

-e follows the link to a target that does not exist and says false; -L looks at the link itself. Test -e "$p" || -L "$p" when you need "anything at this path". -s is the quick check for files that must have content.

Rebuild only when the source is newer

touch -d '1 hour ago' app.min.js
touch app.js
[[ app.js -nt app.min.js ]] && echo "app.js changed, rebuild"
[[ notes.txt -ef link-to-notes ]] && echo "same file via the symlink"
output
app.js changed, rebuild
same file via the symlink

-nt compares modification times; touch -d backdated the output file. This is the check make performs. -ef sees through the symlink: both names lead to the same inode.

String tests

Test True when
-z s the string is empty
-n s the string is not empty
a = b, a == b strings are equal (== is a bash extension in [ ])
a != b strings differ
a < b, a > b a sorts before / after b

In [ ], every variable must be quoted. An empty unquoted variable disappears, leaving [ = x ], and a value with spaces becomes several arguments.

Test for empty and unquoted-variable errors

name=""; title="Release notes"
[[ -z $name ]] && echo "name is empty"
[ -n $name ] && echo "[ -n \$name ] unquoted says non-empty"
[ -n "$name" ] || echo "[ -n \"\$name\" ] quoted says empty"
[ $title = "Release notes" ]
outputexit 2
name is empty
[ -n $name ] unquoted says non-empty
[ -n "$name" ] quoted says empty
/bin/bash: line 5: [: too many arguments

Unquoted, an empty $name vanished and [ -n ] became a one-argument test, which is true for any non-empty word, here -n itself. That is the nastiest version of the bug: no error, just a wrong answer. The last line split $title into two words and [ gave up with too many arguments, status 2.

< and >: a file, not a comparison

Inside [ ], > is a redirection, because [ is a normal command. [ "$a" > "$b" ] runs [ "$a" ] with its output sent to a file named after $b, and it is true whenever $a is non-empty. In [[ ]], < and > compare strings properly. (In [ ] you could write \>.)

[ a > b ] creates a file

a=apple b=banana
[ "$a" > "$b" ] && echo "[ ]: apple > banana?!"
ls banana && echo "...and a file called banana appeared"
[[ $a > $b ]] || echo "[[ ]]: apple sorts before banana"
output
[ ]: apple > banana?!
banana
...and a file called banana appeared
[[ ]]: apple sorts before banana

The [ ] version was "true" and created an empty file named banana. [[ ]] compared the strings, in the current locale's sort order.

Numeric tests

Test Meaning
-eq -ne equal, not equal
-lt -le less than, less or equal
-gt -ge greater than, greater or equal

These compare integers. =, < and > compare strings, which gives the wrong answer for numbers of different lengths. In bash, (( a < b )) is the most readable numeric test (Arithmetic).

Numbers compared as strings go wrong

a=10 b=9
[[ $a < $b ]] && echo "string compare: 10 < 9"
[[ $a -lt $b ]] || echo "numeric -lt: 10 is not less than 9"
(( a > b )) && echo "(( )): 10 > 9"
n=abc
[ "$n" -gt 5 ]
outputexit 2
string compare: 10 < 9
numeric -lt: 10 is not less than 9
(( )): 10 > 9
/bin/bash: line 6: [: abc: integer expression expected

As strings, "10" sorts before "9" because 1 comes before 9. -lt and (( )) compare values. Giving [ a non-number for -gt is an error: integer expression expected, status 2.

Pattern matching with ==

In [[ ]], the right side of == and != is a glob pattern when unquoted: *, ? and [...] work as in filenames, but matched against the string. Quote the right side to compare literally. The left side is never a pattern.

Glob patterns in [[ == ]]

f="backup-2026-03-14.tar.gz"
[[ $f == backup-*.tar.gz ]] && echo "looks like a backup"
[[ $f == *.zip ]] || echo "not a zip"
star='*'
[[ $f == $star ]] && echo "unquoted variable: pattern, matches"
[[ $f == "$star" ]] || echo "quoted variable: literal *, no match"
output
looks like a backup
not a zip
unquoted variable: pattern, matches
quoted variable: literal *, no match

A pattern stored in a variable also acts as a pattern when unquoted. That is useful, and a trap when the variable holds user input you meant to compare literally.

Regular expressions with =~

[[ string =~ regex ]] matches an extended regular expression anywhere in the string (anchor with ^ and $). The whole match lands in BASH_REMATCH[0] and each capture group in BASH_REMATCH[1], [2] and so on. Quoted parts of the regex match literally, so the reliable habit is to put the regex in a variable and use it unquoted.

Match and capture with =~

line=$(grep -m1 ERROR logs/app.log)
re='^([0-9-]+) ([0-9:]+) +([A-Z]+) +(.*)$'
if [[ $line =~ $re ]]; then
  echo "date=${BASH_REMATCH[1]} time=${BASH_REMATCH[2]}"
  echo "level=${BASH_REMATCH[3]} message=${BASH_REMATCH[4]}"
fi
[[ "v2.4.1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "valid version tag"
output
date=2026-03-14 time=08:16:40
level=ERROR message=health check failed: database timeout after 5000ms
valid version tag

The four groups split the first ERROR line of logs/app.log into its parts. Storing the regex in re avoids the quoting rules for spaces and parentheses inside [[ ]].

Variables, combining and negating

-v name is true when a variable is set, even to an empty string (bash 4.2+; pass the name, not $name). Combine tests inside [[ ]] with &&, ||, ! and parentheses. In [ ], use separate commands joined by the shell's && and ||: the -a and -o operators of test are ambiguous with some arguments and marked obsolescent by POSIX.

Combine and negate tests

unset token; debug=""
[[ -v debug ]] && echo "debug is set (to an empty string)"
[[ ! -v token ]] && echo "token is not set"
f=data/users.csv
if [[ -f $f && -r $f && ( $f == *.csv || $f == *.tsv ) ]]; then
  echo "$f is a readable table"
fi
[ -f "$f" ] && [ -s "$f" ] && echo "[ ] form: exists and not empty"
output
debug is set (to an empty string)
token is not set
data/users.csv is a readable table
[ ] form: exists and not empty

Parentheses group inside [[ ]] without escaping. The [ ] version chains two separate commands with the shell's &&, which is the portable way to combine tests.

Related