To test if a file exists in Bash, use [ -e "$path" ]. Use [ -f "$path" ] when the path must resolve to a regular file, not merely any filesystem object. In a Bash-only script, [[ -e $path ]] provides the same existence test with safer expression parsing.
path=${1:?Usage: check-file PATH}
if [ -e "$path" ]; then
printf 'Path exists: %s\n' "$path"
else
printf 'Path does not resolve to an existing object: %s\n' "$path" >&2
fi
That result is a snapshot. Another process can remove or replace the path before the next command uses it, so an existence check is not a lock and does not guarantee that a later read or write will succeed.
Use -e for any path and -f for a regular file
if [ -e "$path" ]; then
printf 'A filesystem object exists at this path\n'
fi
if [ -f "$path" ]; then
printf 'The path resolves to a regular file\n'
fi
-e is true for any path that resolves to an existing filesystem object. That can be a regular file, directory, socket, named pipe, or another supported type. -f is narrower: it is true only when the path resolves to a regular file.
Choose the test that matches the next operation. A configuration loader that only accepts regular files should use -f. Do not use -e alone to determine whether a pathname is occupied: a dangling symbolic link is an existing directory entry even though -e is false because its target does not resolve. Include -L when the link itself counts:
if [ -e "$path" ] || [ -L "$path" ]; then
printf 'The pathname is occupied by an object or symbolic link\n'
fi
This detects an ordinary resolved object or a final symbolic link when the process can search the parent path. Neither test proves that the object will remain unchanged or that an inaccessible pathname is unoccupied.
The current GNU Bash conditional-expression reference defines the available file primaries and their exact meanings.
Bash file test operators
These tests work with test, [ ], and Bash [[ ]]. Unless noted otherwise, they follow a symbolic link and test its target.
| Test | True when the path resolves to... | Typical use |
|---|---|---|
-e |
Any existing filesystem object after resolving symbolic links | Check whether a pathname resolves |
-f |
A regular file | Read a config, report, or ordinary log file |
-d |
A directory | Validate an input or output directory |
-L or -h |
A symbolic link itself | Inspect deployment or configuration links |
-s |
An existing object with size greater than zero | Reject an empty input |
-r |
An object readable by the current process | Preflight a read |
-w |
An object writable by the current process | Preflight a write |
-x |
An object for which execute permission is granted, or search permission for a directory | Check execution permission or directory traversal |
-p |
A named pipe, also called a FIFO | Validate pipe-based input |
-S |
A socket | Validate a Unix socket path |
These meanings are also specified by the current POSIX test utility. Type tests are not interchangeable. A socket is present according to -e, for example, but it is not a regular file according to -f.
Combine type and state tests when both properties are required:
report=${1:?Usage: process-report FILE}
if [ -f "$report" ] && [ -s "$report" ]; then
printf 'Regular, non-empty report: %s\n' "$report"
else
printf 'Report does not resolve, is empty, or is not a regular file\n' >&2
fi
-s alone does not mean “non-empty regular file.” Pair it with -f when regular-file semantics matter.
Choose [ ] or [[ ]] deliberately
test and [ ] are the portable forms. The following two expressions mean the same thing:
test -f "$path"
[ -f "$path" ]
The opening [ is a command name, and the closing ] is its required final argument. Every part must therefore be a separate shell token. Spaces are required: [ -f "$path" ] is valid, while forms that attach a bracket to another token are not.
Quote pathname expansions in test and [ ]. Quoting keeps an empty value or a name containing spaces or wildcard characters as one argument. The GNU Coreutils test documentation explicitly notes that every part of an expression must be a separate argument.
Use [[ ]] only when the script is intentionally Bash:
if [[ -f $path ]]; then
printf 'Regular file: %s\n' "$path"
fi
Inside [[ ]], Bash does not perform word splitting or pathname expansion on the words in the expression. This makes an unquoted $path safe for this file test. Quoting it is also valid and can make the pathname intent clearer:
if [[ -f "$path" ]]; then
printf 'Regular file: %s\n' "$path"
fi
Do not quote the conditional operator itself: write [[ -f $path ]], not an expression where -f is quoted. The Bash conditional-construct documentation states both rules: [[ ]] suppresses word splitting and filename expansion, while conditional operators must remain unquoted to be recognized.
Use [ ] in scripts that may run as /bin/sh. Use [[ ]] when the shebang and deployment environment guarantee Bash and compound conditions benefit from its clearer grammar.
Test when a path does not resolve or has the wrong type
Put ! before the file primary to negate it:
if [ ! -e "$path" ]; then
printf 'Path does not resolve to an existing object: %s\n' "$path" >&2
fi
if [[ ! -f $path ]]; then
printf 'Not a regular file: %s\n' "$path" >&2
fi
“Not a regular file” is broader than “does not resolve.” A directory, socket, named pipe, and broken symbolic link all fail -f. Use elif when the message or action needs to distinguish those cases:
if [ -f "$path" ]; then
printf 'Regular file\n'
elif [ -e "$path" ]; then
printf 'Path exists but is not a regular file\n' >&2
else
printf 'Path does not resolve to an existing object\n' >&2
fi
Combine multiple file conditions safely
With portable [ ], keep each test complete and combine their command statuses with the shell's && or || operators:
config=${1:?Usage: load-config FILE}
if [ -f "$config" ] && [ -r "$config" ]; then
printf 'Config is a readable regular file\n'
else
printf 'Config does not resolve, is unreadable, or has the wrong type\n' >&2
fi
For alternatives:
if [ -f "$primary" ] || [ -f "$fallback" ]; then
printf 'At least one configuration file exists\n'
fi
In Bash, one [[ ]] expression can contain the logical operators:
if [[ -f $config && -r $config ]]; then
printf 'Config is a readable regular file\n'
fi
if [[ -e $first || -e $second ]]; then
printf 'At least one path exists\n'
fi
Avoid the historical -a and -o binary operators inside test or [ ]. POSIX removed them because expressions could be ambiguous. Separate [ ] commands joined by shell && or ||, or use Bash [[ ]], instead.
Handle symbolic links, including broken links
Most file tests dereference a symbolic link and inspect its target. -L and -h are the exceptions: they inspect whether the pathname itself is a symbolic link.
if [ -L "$path" ]; then
if [ -e "$path" ]; then
printf 'Symbolic link with an existing target\n'
else
printf 'Broken symbolic link\n' >&2
fi
elif [ -e "$path" ]; then
printf 'Existing path that is not a symbolic link\n'
else
printf 'Path does not resolve to an existing object\n'
fi
A broken link is therefore true for -L but false for -e: the directory entry for the link exists, but its target cannot be resolved. The same target-following rule applies to -f, -d, -r, -w, and the other ordinary file tests.
If symlink replacement is part of the threat model, a separate Bash check cannot secure a later open. The application performing the sensitive operation must use operating-system primitives and path-resolution rules appropriate to that operation.
File tests return exit status
Shell conditions run commands and branch on their exit status. For the POSIX test utility, status 0 means true, 1 means false or a missing expression, and a value greater than 1 means an error.
Use the test directly in if rather than running it and inspecting $? later:
if test -d "$path"; then
printf 'Directory exists\n'
else
printf 'Directory is missing or inaccessible\n' >&2
fi
This keeps the result next to the branch that consumes it. It also avoids accidentally overwriting $? with the status of an intervening command.
For an actual read or parse, run the required consumer and handle the status defined by that consumer's documentation. Do not insert a separate existence or permission check and assume it guarantees success. If the consumer uses different nonzero statuses for invalid input, access failure, and internal errors, preserve those distinctions in the script's message and exit status.
Permission checks are only preflight checks
-r, -w, and -x answer whether access appears permitted for the process performing the test. The result can depend on effective credentials and filesystem access rules, not just the three familiar owner/group/other mode digits. For a directory, -x means search or traversal permission rather than “execute this directory.”
A positive permission test still does not guarantee a later operation. The path can change, access-control data can change, a mount can become read-only, or the actual operation can fail for another reason. A negative test also should not be rewritten as a blanket chmod, chown, or privileged retry.
When possible, attempt the required operation once and handle its real result. Use a permission test only when it improves the error message or prevents expensive setup. The operation that opens, creates, renames, or writes the file is authoritative.
Existence checks do not prevent race conditions
The sequence “observe [ ! -e "$path" ], then create the path” has a time-of-check-to-time-of-use race. A dangling symbolic link can already occupy the pathname, and another process can create an entry between the check and the creation attempt. The same problem affects “check, then read,” “check, then delete,” and “check, then change permissions.”
Do not use [ ! -e "$lock" ] followed by a normal file creation as an exclusive lock. Bash file tests only report the state observed during the test.
On Linux, an application can request open() with O_CREAT|O_EXCL. The kernel then makes the existence check and creation one operation: it fails with EEXIST if the pathname already exists, and it does not follow a symbolic link at the final component. The Linux open(2) manual also documents important filesystem qualifications, including older NFS environments where relying on O_EXCL for locking can still race.
Use a maintained language, runtime, or platform facility that exposes the required atomic primitive and documents its filesystem assumptions. File creation is not automatically a complete lock protocol: ownership, stale-lock recovery, process death, network filesystems, and timeout behavior still need explicit handling. Avoid homemade locking based only on Bash existence tests.
For ordinary reads, opening the file first also narrows the race. An open file descriptor continues to refer to the opened object even if the pathname is later removed or changed to refer to another file. Code with stricter path-traversal requirements should use the appropriate openat() family interfaces rather than assuming a shell test secures the path.
Check whether a group of files exists without parsing ls
Do not parse ls output to decide whether files exist. Filenames can contain spaces, tabs, wildcard characters, and newlines, while formatted display output is not a stable data interface.
For a Bash-only script, nullglob lets an unmatched pattern expand to zero array elements instead of remaining as a literal pattern:
shopt -s nullglob
log_files=(/var/log/checkout/*.log)
if ((${#log_files[@]} == 0)); then
printf 'No matching log files\n' >&2
else
for path in "${log_files[@]}"; do
printf 'Matched: %q\n' "$path"
done
fi
The array preserves every matched filename as one element. Quoting "${log_files[@]}" preserves that boundary during iteration. According to the Bash filename-expansion documentation, nullglob removes an unmatched pattern; without it, Bash leaves the pattern unchanged.
This pattern does not include names beginning with . unless the pattern explicitly begins with a dot or dotglob is enabled. It also reports only the matches visible at expansion time. Files can still be added, removed, or replaced before the loop reaches them.
Log failed file checks without leaking paths or secrets
An unavailable input can be useful operational evidence. Emit a structured, bounded event to standard error, but avoid logging an arbitrary full pathname when it may contain usernames, tenant identifiers, tokens, or confidential filenames.
printf '%s\n' \
'{"severity":"ERROR","service":"import-worker","event":"input_file_unavailable","path_class":"configured_import","message":"Required input file is unavailable"}' \
>&2
Use a stable path class or internal identifier rather than raw user input. Protect and retain these events according to the same rules as other application logs. The log-management best-practices guide covers redaction, access, retention, and pipeline verification. If the script may create a missing output, check the target directory and permissions first; use an exclusive creation operation when overwriting an existing file would be unsafe.
Fluxtail is a paid, logs-focused service with self-service Starter and Pro plans. After a separately configured collector sends the script's standard output or standard error through a documented receiver, Fluxtail provides named streams, Live Tail, search, filters, and alerts for the fields the collector actually delivers. Verify the structured event and mapped field names before relying on them in a filter or alert.
Copy-safe checklist
- Use
[ -e "$path" ]when any resolved filesystem object is acceptable. - Use
[ -e "$path" ] || [ -L "$path" ]when a dangling symbolic link also means the pathname is occupied. - Use
[ -f "$path" ]when the path must resolve to a regular file. - Use
-Lor-hto test the symbolic link itself; a broken link is false for-e. - Quote pathname expansions in
[ ]andtest; keep every bracket and operator as a separate token. - Use
[[ ]]only in Bash scripts. It suppresses word splitting and pathname expansion, but conditional operators stay unquoted. - Combine portable tests as
[ condition ] && [ condition ]; avoid historical-aand-oexpressions. - Treat
-r,-w, and-xas preflight information, not proof that the operation will succeed. - Treat every file test as a snapshot. Attempt the operation and handle its result whenever possible.
- Use documented atomic creation or locking primitives for exclusivity; Bash file tests are not locks.
- Use arrays and
nullglobfor Bash file lists instead of parsingls.