Linux Basic — File Test Operators

5 minute read

File Test Operators

Bash includes a built-in family of file test operators — single-letter flags like -e and -f that you use inside [[ ... ]] to ask questions about a file. They’re fast (no external process), portable across Bash, and the canonical way to write defensive scripts.


The Setup

mkdir -p /tmp/tests
cd /tmp/tests
touch regular.txt
mkdir adir
ln -s regular.txt link.txt
ln -s nonexistent broken.lnk
chmod 0000 noaccess.txt
echo "data" > full.txt
> empty.txt

All examples below assume you’re in /tmp/tests.


Existence and Type

Operator True when
-e FILE exists (any type — file, dir, symlink, device, …)
-f FILE exists and is a regular file
-d FILE exists and is a directory
-L FILE exists and is a symbolic link
-h FILE same as -L
-b FILE block special device
-c FILE character special device
-p FILE named pipe (FIFO)
-S FILE socket
-s FILE exists and size > 0 (non-empty)
[[ -e regular.txt ]] && echo exists
# exists

[[ -f regular.txt ]] && echo "regular file"
# regular file

[[ -d adir ]] && echo directory
# directory

[[ -L link.txt ]] && echo symlink
# symlink

# -f follows symlinks; -L does not
[[ -f link.txt ]] && echo "link points to a regular file"
# link points to a regular file

# -e returns true for a broken symlink (it 'exists' as a link)?
[[ -e broken.lnk ]] && echo "broken exists" || echo "broken doesn't exist"
# broken doesn't exist     ← -e follows the link and the target is missing

# But -L is true regardless
[[ -L broken.lnk ]] && echo "broken is a link"
# broken is a link

That last pair is the canonical way to detect a dangling symlink:

[[ -L broken.lnk && ! -e broken.lnk ]] && echo "dangling symlink"
# dangling symlink

Size

[[ -s full.txt  ]] && echo "full has data"
# full has data

[[ -s empty.txt ]] || echo "empty is empty"
# empty is empty

-s is a single, clean way to say “the file has bytes”. Combine with -f:

[[ -f data.json && -s data.json ]] || { echo "no data" >&2; exit 1; }

Readability, Writability, Executability

These check the current process’s access — they take user, group, and ACLs into account, not just the mode bits.

Operator True when
-r FILE readable by the current user
-w FILE writable by the current user
-x FILE executable (file) or searchable (dir) by the current user
[[ -r regular.txt ]] && echo readable
# readable

[[ -r noaccess.txt ]] || echo "cannot read"
# cannot read

# Useful for guarding before opening a file
if [[ ! -r config.json ]]; then
    echo "config.json missing or not readable" >&2
    exit 1
fi

For directories, -x means “can I list/enter this directory”:

[[ -x /etc ]] && echo "/etc traversable"
# /etc traversable

Ownership

Operator True when
-O FILE owned by the current user
-G FILE owned by a group the current user belongs to
-u FILE has the setuid bit
-g FILE has the setgid bit
-k FILE has the sticky bit
[[ -O regular.txt ]] && echo "I own it"
# I own it

[[ -O /etc/passwd ]] || echo "I don't own /etc/passwd"
# I don't own /etc/passwd

Timestamp Comparisons

Operator True when
FILE1 -nt FILE2 FILE1 newer than FILE2 (mtime)
FILE1 -ot FILE2 FILE1 older than FILE2
FILE1 -ef FILE2 FILE1 and FILE2 are the same file (same inode)
touch -d '2024-01-01' old.txt
touch -d '2026-01-01' new.txt
[[ new.txt -nt old.txt ]] && echo "new is newer"
# new is newer

# Hard links share an inode → -ef is true
ln regular.txt regular.alias
[[ regular.txt -ef regular.alias ]] && echo "same file"
# same file

-nt is hugely useful in build scripts: “rebuild only if source is newer than artefact”:

[[ src.c -nt out.o ]] && gcc -c src.c -o out.o

String Tests Used Alongside File Tests

These are not file tests, but they appear in every defensive check so they deserve a row:

Operator True when
-z STR string is empty
-n STR string is non-empty
STR1 == STR2 strings equal (use = for POSIX [ ])
STR1 != STR2 strings not equal
file=""
[[ -z "$file" ]] && echo "empty"; [[ -n "$file" ]] || echo "still empty"
# empty
# still empty

This is why the safe deletion guard is:

[[ -n "$dir" && -d "$dir" ]] && rm -rf -- "$dir"

Always Use [[ ... ]], Not [ ... ]

Bash has two test forms — both work, but [[ ... ]] is safer:

Feature [ ... ] (POSIX) [[ ... ]] (Bash)
Word splitting inside test yes (you must quote) no
Glob expansion inside test yes no
&& / \|\| inside no — use -a / -o yes
Pattern matching == no yes ([[ $x == foo* ]])
Regex =~ no yes
# ❌ With [ ... ] this breaks if $file has spaces
[ -f $file ] && echo found

# ✅ With [[ ... ]] you're safe (but quoting is still good practice)
[[ -f $file ]] && echo found

Stick with [[ ... ]] everywhere unless you’re writing strictly POSIX sh.


A One-Glance Cheat Sheet

# Existence / type
-e exists   -f regular   -d dir   -L symlink   -s non-empty
-b block    -c char      -p fifo  -S socket

# Access
-r readable -w writable  -x executable / searchable

# Ownership
-O mine     -G my-group  -u setuid  -g setgid  -k sticky

# Comparisons
F1 -nt F2   F1 -ot F2    F1 -ef F2    # newer than / older than / same inode

# String
-z empty    -n non-empty STR1 == STR2 STR1 != STR2

Common Mistakes

❌ Wrong ✅ Right Why
[ -f $file ] [[ -f $file ]] or [ -f "$file" ] unquoted in [ ] splits on spaces
[ -e f && -r f ] [ -e f ] && [ -r f ] or [[ -e f && -r f ]] && only works inside [[ ]]
[ -f f -a -r f ] [[ -f f && -r f ]] -a / -o are deprecated, error-prone
[[ -e link ]] to detect dangling [[ -L link && ! -e link ]] -e follows the link
[[ -x dir ]] to check write -x on a dir means search, not write — use -w confused semantics

Next → Defensive File Handling

Updated: