Linux Basic — File Test Operators
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