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

Bash check if file exists with [[ -f ]], test -e, and [ ]; bash if file exists patterns with if/else; symlinks and broken links; check if file exists on Linux with find and stat; quoting, exit codes, and pitfalls.

Published

Updated

Read time 8 min read

Reviewed byDeepak Prasad

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

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 …