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
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"
-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"
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" ]
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"
[ ]: 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 ]
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"
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"
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"
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
testEvaluate a condition about files, strings or integers and return 0 for true or 1 for false.lnMake links between files, symbolic (-s) or hard.touchUpdate a file's timestamps, creating it empty if it does not exist.statShow a file's metadata (size, permissions, owner, inode, timestamps), or print chosen fields in your own format.- ConditionalsHow if, elif, &&, || and ! decide using exit statuses, the three test syntaxes, guard clauses, and why
a && b || cis not an if-else. - ArithmeticInteger math with $(( )) and (( )), the exit status of (( )) that breaks set -e, leading-zero octal surprises, and what to use for decimals.
- QuotingSingle quotes, double quotes, backslashes and $'...', what each protects, and the unquoted-variable bugs they prevent, each shown failing and then fixed.
- GlobbingHow *, ? and [...] match file names, what happens when nothing matches, and the shell options nullglob, failglob, dotglob, globstar and extglob.
- The case statementMatching one value against glob patterns with case, alternatives with |, the default branch, fall-through with ;& and ;;&, and parsing options with case.