Bash check if file exists: [[ -f ]], test, symlinks, and Linux examples

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 command / 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.


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:

bash
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
done

Output from that loop:

text
goodlink.txt:  -e -f -L
badlink.txt:  !-e !-f -L

Hidden 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:

bash
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"
fi

On a typical Linux machine that file is present; one possible run prints:

text
regular file (or symlink to one): /etc/os-release

For 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:

bash
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:

bash
test -f /etc/hostname && echo "test: ok"
[ -f /etc/hostname ] && echo "[ : ok"
[[ -f /etc/hostname ]] && echo "[[ : ok"

Output from that loop:

text
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:

bash
if ! [[ -f /etc/passwd ]]; then
  echo "unexpected: missing /etc/passwd" >&2
  exit 1
fi

If 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:

bash
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:

text
ready

Return 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:

bash
shopt -s nullglob
for path in /usr/bin/git*; do
  [[ -x "$path" ]] || continue
  echo "executable: $path"
done

nullglob 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:

bash
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):

text
/tmp/tmp.zIp9uxIBvv/report.txt

stat shows the file type in words, which is helpful when debugging odd paths:

bash
TMP=$(mktemp) && echo x >"$TMP" && stat -c '%F %n' "$TMP"; rm -f "$TMP"

One line from stat looked like:

text
regular file /tmp/tmp.sA7nwM530J

stat 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 nearby.


Common mistakes and safer habits

Quoting paths with spaces

Word splitting is the classic foot-gun. With a file literally named a b:

bash
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:

text
[ quoted: ok
bash: [: a: binary operator expected

Inside [[ ... ]], 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.

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)