Before you read, copy, or delete something from a script, it pays to ask Bash what is
actually sitting on that path: plain file, directory, symlink, or nothing. This piece
sticks to the builtins ([[, [, test), calls out the symlink edge cases that
trip people up, and ends with find / stat for when a single test is not enough.
Tested all the commands and code from this article on Ubuntu 25.04, kernel 6.14.0-37-generic, Bash 5.2.37.
Choose the right test: exists, regular file, directory, symlink
Use -e when you only care that some filesystem object exists at the path (file,
directory, device node, or symlink that still resolves). Use -f when you need a
regular file (or a symlink whose final target is a regular file). Use -d for
directories. Use -h or -L when the path itself must be a symbolic link, which
matters for configuration layouts that use
symlinks.
Broken symlinks illustrate the difference: the link name is there on disk, but the
target is missing, so -L is still true while -e and -f are false. In a scratch
directory after echo hello > real.txt, ln -s real.txt goodlink.txt, and ln -s missing.txt badlink.txt, these checks produced:
for f in goodlink.txt badlink.txt; do
printf '%s: ' "$f"
[[ -e $f ]] && echo -n ' -e' || echo -n ' !-e'
[[ -f $f ]] && echo -n ' -f' || echo -n ' !-f'
[[ -L $f ]] && echo -n ' -L' || echo -n ' !-L'
echo
doneOutput from that loop:
goodlink.txt: -e -f -L
badlink.txt: !-e !-f -LHidden dotfiles follow the same rules as other paths; only globbing and ls defaults
treat them specially, which is why existence checks should always use the path string
you were given, not a reconstructed name without the leading dot. See
hidden files when you
build file lists.
Bash if file exists: [[ ]] with if / else
Double brackets are a Bash keyword: pattern matching and logical operators are a bit
more forgiving than the older [ command, and you avoid spawning a separate test
process (minor, but tidy). A straightforward bash if file exists pattern looks like
this:
path="/etc/os-release"
if [[ -f "$path" ]]; then
echo "regular file (or symlink to one): $path"
elif [[ -d "$path" ]]; then
echo "directory: $path"
elif [[ -e "$path" ]]; then
echo "exists but is not a plain file or dir: $path"
else
echo "missing: $path"
fiOn a typical Linux machine that file is present; one possible run prints:
regular file (or symlink to one): /etc/os-releaseFor a path you know should be a directory (for example before writing logs), use a narrow test so you do not accidentally accept a file with the same name:
dir="/var/tmp"
if [[ -d "$dir" && -w "$dir" ]]; then
echo "writable directory: $dir"
else
echo "not a writable directory: $dir"
fi[ ], [[ ]], and the test command
Single brackets are implemented as the test builtin. These forms are equivalent for
many file checks:
test -f /etc/hostname && echo "test: ok"
[ -f /etc/hostname ] && echo "[ : ok"
[[ -f /etc/hostname ]] && echo "[[ : ok"Output from that loop:
test: ok
[ : ok
[[ : ok[[ is not POSIX sh, but it is the usual choice inside Bash-only scripts. If you
must target plain sh, stick to [ / test and keep variables quoted.
Operator quick reference
Unary file tests you use most often in automation:
| Test | Meaning |
|---|---|
-e FILE |
True if FILE exists |
-f FILE |
True if FILE exists and is a regular file (follows symlinks to the target) |
-d FILE |
True if FILE exists and is a directory |
-h FILE / -L FILE |
True if FILE exists and is a symbolic link |
-b FILE |
True if FILE is a block special device |
-c FILE |
True if FILE is a character special device |
-p FILE |
True if FILE is a named pipe (FIFO) |
-S FILE |
True if FILE is a socket |
-s FILE |
True if FILE exists and has size greater than zero |
-r FILE |
True if FILE is readable |
-w FILE |
True if FILE is writable |
-x FILE |
True if FILE is executable |
-g FILE |
True if FILE exists and its set-group-id bit is set |
-u FILE |
True if FILE exists and its set-user-id bit is set |
-k FILE |
True if FILE exists and its sticky bit is set |
-G FILE |
True if FILE exists and is owned by the effective group ID |
-O FILE |
True if FILE exists and is owned by the effective user ID |
-N FILE |
True if FILE exists and was modified since it was last read |
Two-path comparisons:
| Test | Meaning |
|---|---|
FILE1 -ef FILE2 |
Same device and inode (hard link test) |
FILE1 -nt FILE2 |
FILE1 newer than FILE2, or FILE1 exists and FILE2 does not |
FILE1 -ot FILE2 |
FILE1 older than FILE2, or FILE2 exists and FILE1 does not |
Exit codes and $?
A successful test yields status 0. Instead of re-running work, you can branch on the
status right away:
if ! [[ -f /etc/passwd ]]; then
echo "unexpected: missing /etc/passwd" >&2
exit 1
fiIf you truly need $?, read it immediately after the command you care about; another
builtin in between will overwrite it. Storing rc=$? on the next line is the usual
pattern when you log failures.
Functions for reusable checks
Wrapping tests in a Bash function keeps intent obvious when many paths are validated the same way:
require_file() {
local p="$1"
if [[ -f "$p" ]]; then
return 0
fi
printf 'error: not a regular file: %s\n' "$p" >&2
return 1
}
require_file /etc/os-release && echo "ready"Example run:
readyReturn 0 for success and non-zero for failure so callers can use if require_file ...; then naturally.
Loops with file checks
When you combine globs with tests, always quote the expansion so paths with spaces do not split:
shopt -s nullglob
for path in /usr/bin/git*; do
[[ -x "$path" ]] || continue
echo "executable: $path"
donenullglob avoids the literal pattern string appearing as a pseudo-file when nothing
matches. For reading lists from a file, a while read -r loop plus [[ -d "$line" ]]
is common; keep -r so backslashes in paths are preserved.
Alternatives on Linux: find, stat, and pipelines
Sometimes you need recursion or metadata instead of a single [[ test.
find searches trees and prints matches. A self-contained example:
d=$(mktemp -d)
touch "$d/report.txt"
find "$d" -maxdepth 1 -type f -name 'report.txt' -print
rm -rf "$d"Example path (the tmp.* bit changes every run):
/tmp/tmp.zIp9uxIBvv/report.txtstat shows the file type in words, which is helpful when debugging odd paths:
TMP=$(mktemp) && echo x >"$TMP" && stat -c '%F %n' "$TMP"; rm -f "$TMP"One line from stat looked like:
regular file /tmp/tmp.sA7nwM530Jstat does not replace a boolean test in a script; you still parse fields or stick to
[[ for control flow. For a broader command palette, keep a
Linux commands cheat sheet nearby.
Common mistakes and safer habits
Quoting paths with spaces
Word splitting is the classic foot-gun. With a file literally named a b:
touch 'a b'
name='a b'
# Always safe in Bash and POSIX [:
[ -f "$name" ] && echo '[ quoted: ok'
# Unquoted: [ sees too many arguments after -f:
[ -f $name ] && echo 'this branch should not run'Typical failure from the unquoted [ form:
[ quoted: ok
bash: [: a: binary operator expectedInside [[ ... ]], Bash parses the conditional as a single grammar rule, so
pathname expansion and the usual word splitting rules for command arguments do not
bite the same way they do for [ or for normal commands. Even so, keep paths
quoted everywhere: POSIX scripts still use [, and any expansion outside [[ ... ]]
still splits.
Using ls to infer existence
ls errors when the path is missing, but parsing ls output is brittle (locale,
colors, weird filenames). Prefer [[ -e "$path" ]] or [[ -f "$path" ]] directly.
Time-of-check to time-of-use
Between your test and the open/cp/mv, another process can replace the file.
High-risk code should use dedicated APIs (O_NOFOLLOW, atomic mkdir, temp files
with restrictive permissions). For everyday scripting, documenting the race is
enough; pretending [[ -f ]] is a lock is not.
Mixing -a / -o inside [ ]
Portable style uses && / || between separate tests instead of cramming -a into
one bracket expression, which is easier to read and harder to mis-parenthesize.
Summary
Start with [[ -e "$path" ]] when anything at that name counts, switch to -f or -d
when the type matters, and remember -L when you care about the symlink itself.
Broken targets are the usual wake-up call for why -e and -f are not the same as
-L. Wire tests into normal if / elif / else, keep paths quoted (especially for
[), skip ls as an existence check, and pull in find or stat when you need a
tree walk or richer metadata. Tiny functions and clear exit codes make the same
patterns easier to reuse the next time you touch the script.

