One of the habits that separates a fragile script from a reliable one is checking whether a file exists before you try to read it, write to it, or delete it. I learned this the hard way early on, when a script of mine tried to append logs to a file that had been moved, and the whole thing failed silently in a way that took me an embarrassingly long time to track down. In this article, I’ll walk through the different ways Bash lets you check for file existence, along with the subtle differences between them that actually matter in practice.
Why Checking File Existence Matters
Scripts often run unattended — as cron jobs, in CI/CD pipelines, or as background services. If a script assumes a file is there and it isn’t, you can end up with cryptic errors, corrupted output, or worse, a script that silently does the wrong thing. Checking for existence up front lets you handle these situations gracefully, whether that means creating the file, skipping a step, or exiting with a clear error message.
The Basic Test: -e
The most general test for existence is -e, which checks whether a path exists at all, regardless of whether it’s a file, directory, symlink, or something else.
if [ -e "myfile.txt" ]; then
echo "myfile.txt exists."
else
echo "myfile.txt does not exist."
fi
Checking Specifically for a Regular File: -f
Most of the time, you don’t just want to know if something exists — you want to know if it’s specifically a regular file (as opposed to a directory or a special device file). That’s what -f is for:
if [ -f "myfile.txt" ]; then
echo "myfile.txt is a regular file."
else
echo "myfile.txt does not exist or is not a regular file."
fi
This is the test I reach for most often, since I usually care about the difference between a file and a directory.
Checking for a Directory: -d
if [ -d "myfolder" ]; then
echo "myfolder is a directory."
fi
This is covered in more detail in the directories article, but it’s worth mentioning here since it’s easy to confuse -d and -f when you’re new to Bash.
Checking for Readability, Writability, and Executability
Beyond simple existence, Bash lets you test permissions directly:
if [ -r "myfile.txt" ]; then
echo "File is readable."
fi
if [ -w "myfile.txt" ]; then
echo "File is writable."
fi
if [ -x "myscript.sh" ]; then
echo "File is executable."
fi
These are especially useful before attempting an operation that would otherwise fail with a permission error.
Checking for a Symbolic Link: -L
if [ -L "mylink" ]; then
echo "mylink is a symbolic link."
fi
This checks whether the path itself is a symlink, regardless of whether the target it points to actually exists.
Checking for an Empty File: -s
The -s test is a bit different — it checks whether a file exists and has a size greater than zero:
if [ -s "myfile.txt" ]; then
echo "File exists and is not empty."
else
echo "File is empty or doesn't exist."
fi
I use this a lot when validating that a download or export actually produced meaningful content, rather than just an empty placeholder.
Using [[ ]] Instead of [ ]
Modern Bash scripts often use double brackets instead of single ones:
if [[ -f "myfile.txt" ]]; then
echo "File exists."
fi
Functionally, for these existence tests, [[ ]] and [ ] behave the same way, but [[ ]] is a Bash keyword rather than an external command, and it handles things like unquoted variables and pattern matching more safely. I generally recommend [[ ]] for any script that’s Bash-specific rather than aiming for POSIX sh compatibility.
Combining Checks with Logical Operators
You can combine multiple existence checks:
if [ -f "config.txt" ] && [ -r "config.txt" ]; then
echo "config.txt exists and is readable."
fi
Or using [[ ]] syntax with && directly inside the brackets:
if [[ -f "config.txt" && -r "config.txt" ]]; then
echo "config.txt exists and is readable."
fi
Checking Existence Without an if Statement
Sometimes you just want a quick one-liner, using && and ||:
[ -f "myfile.txt" ] && echo "Exists" || echo "Does not exist"
This works, but be cautious: if the echo "Exists" command itself fails for some reason, the || branch will also run. For anything beyond a quick throwaway check, a proper if statement is safer.
How These Tests Work Internally
Under the hood, these test operators rely on the stat() system call, which retrieves metadata about a file — its type, size, permissions, and timestamps — without opening or reading its contents. When you run [ -f "file" ], Bash (or the external test command, depending on context) calls stat() on the path and inspects the returned file type field to determine if it corresponds to a regular file. If the path doesn’t exist at all, stat() returns an error, and the test simply evaluates to false. This is why these checks are fast — they don’t need to open or scan the file, just query the file system’s metadata.
Real-World Use Cases
Skipping a step in a setup script if a config already exists:
#!/bin/bash
if [ ! -f "app.conf" ]; then
echo "No config found, generating default..."
echo "mode=default" > app.conf
else
echo "Config already present, skipping generation."
fi
Validating a required input file before processing:
#!/bin/bash
INPUT_FILE="$1"
if [ -z "$INPUT_FILE" ] || [ ! -f "$INPUT_FILE" ]; then
echo "Error: please provide a valid input file." >&2
exit 1
fi
echo "Processing $INPUT_FILE..."
Waiting for a file to appear (common in pipeline scripts):
#!/bin/bash
FILE_TO_WATCH="/tmp/ready.flag"
while [ ! -f "$FILE_TO_WATCH" ]; do
echo "Waiting for $FILE_TO_WATCH..."
sleep 2
done
echo "File detected, continuing."
Automation Example: Safe Backup Verification
#!/bin/bash
BACKUP_FILE="/backups/latest.tar.gz"
if [ -f "$BACKUP_FILE" ] && [ -s "$BACKUP_FILE" ]; then
echo "Backup exists and is not empty: $BACKUP_FILE"
else
echo "Backup missing or empty! Sending alert..." >&2
# mail -s "Backup failed" admin@example.com < /dev/null
exit 1
fi
This kind of check is common in monitoring scripts that verify a nightly backup actually completed successfully rather than just assuming it did.
Best Practices
- Always quote your variables inside test brackets:
[ -f "$file" ]rather than[ -f $file ], to avoid word-splitting issues with filenames containing spaces. - Use
-frather than-ewhen you specifically care about regular files, not directories or other special files. - Combine existence checks with permission checks (
-r,-w,-x) when your script is about to read, write, or execute the file. - Prefer
[[ ]]in Bash-only scripts for its safer parsing behavior. - Use
-sto catch the common bug of a script producing an empty output file and treating it as a success.
Security Considerations
- Be cautious with symlinks:
-ffollows symlinks, so[ -f "$path" ]will return true if$pathis a symlink pointing to a valid regular file, even if the symlink itself could be pointing somewhere unexpected. If you need to detect the symlink specifically, use-L. - Avoid time-of-check to time-of-use (TOCTOU) issues: checking that a file exists and then acting on it a moment later can be exploited in multi-user systems if another process changes the file in between. For sensitive operations, consider atomic alternatives like
mkdir(which fails if the directory already exists) instead of check-then-act patterns. - When checking files supplied by user input, validate the path doesn’t contain unexpected characters or attempt directory traversal (like
../../etc/passwd) before using it in further commands.
Optimization Tips
- Existence checks are cheap (a single
stat()call), so don’t be afraid to add them liberally in scripts where robustness matters more than shaving microseconds. - When checking many files in a loop, consider using
findwith-type ffor the whole batch instead of looping through hundreds of individual[ -f ]checks, sincefindis optimized for directory traversal.
Troubleshooting
A file check passes but the script still fails to read the file: This usually means the file exists but you lack read permission. Add an -r check alongside -f to catch this.
[ -f "$file" ] behaves unexpectedly with an empty variable: If $file is unset or empty, [ -f "" ] correctly evaluates to false, but downstream logic can get confused if you expected an error instead. Add a check for -z "$file" first.
Existence check works in the terminal but fails in a script: Double-check that the script isn’t running with a different working directory than you expect — relative paths depend on the current directory (pwd), which can differ between interactive shells and scripts launched by cron.
Common Mistakes
- Using
-ewhen you actually meant-f, leading to unexpected behavior when the path turns out to be a directory. - Forgetting to quote variables inside test brackets, which can break on filenames with spaces.
- Assuming a file’s existence guarantees it’s readable — permissions are a separate check.
- Using outdated single-bracket syntax with complex logical conditions that would be safer in double brackets.
FAQs
What’s the difference between -e and -f? -e checks if a path exists at all, regardless of type. -f checks specifically that it exists and is a regular file.
How do I check if a file does NOT exist? Use the negation operator: if [ ! -f "myfile.txt" ]; then ... fi.
Can I check for multiple files at once? Yes, by combining checks with && or looping through an array of filenames.
Does -f follow symbolic links? Yes. If the symlink points to a valid regular file, -f will return true.
Summary
Checking for file existence is a small habit that prevents a lot of larger problems down the line. Bash gives you a rich set of test operators — -e, -f, -d, -r, -w, -x, -s, and -L — that let you verify not just whether something exists, but what kind of thing it is and whether you’re allowed to interact with it. Building these checks into your scripts, especially ones that run unattended, is one of the simplest ways to make your automation more resilient.
References
- Bash Reference Manual (Conditional Expressions): https://www.gnu.org/software/bash/manual/bash.html#Bash-Conditional-Expressions
- GNU Coreutils Manual: https://www.gnu.org/software/coreutils/manual/coreutils.html
test(1)man page: https://man7.org/linux/man-pages/man1/test.1.html