| |

Bash Scripting 23 ๐Ÿง File Testing โ€” Checking file existence, permissions, and types

โฑ๏ธ Estimated read: 13 min | |

A Bash script that operates on files must check what it is operating on before it operates. The test command and its [[ ]] compound equivalent provide unary file operators that answer three questions: does the path exist, what type of file is it, and does the current user have permission to access it in the way the script intends. These checks are not a substitute for error handlingโ€”a file can disappear between the check and the operationโ€”but they catch the common cases before a command fails with an opaque error message.

This chapter covers the file-testing operators in three categories: existence and type (-e, -f, -d, -L, -b, -c, -p, -S), permissions (-r, -w, -x), and attributes (-s, -O, -G, -N). It explains the difference between testing a symbolic link and testing its target, the root-user exception that makes -x and -r unreliable in some contexts, and the time-of-check-to-time-of-use race that means a passing test is not a guarantee.

Key point: The file-testing operators follow symbolic links by default. -f symlink tests the target of the link, not the link itself. To test the link, use -L or -h. A broken symbolic link passes -L but fails -e.


Why file testing has these distinctions

The existence versus type problem. A path can exist and be a regular file, a directory, a symbolic link, a block device, a character device, a named pipe, or a socket. A script that expects to read a regular file will behave differently if the path is a directory or a FIFO. The -e operator answers “does anything exist at this path,” while -f, -d, and the other type operators answer “what kind of thing is it” .

The permission versus access problem. The -r, -w, and -x operators test whether the current user has the corresponding permission on the file. But the GNU Autoconf manual documents two complications. First, POSIX allows implementations of -x to succeed for the root user even when no execute bits are set, because root can execute any file . Second, shells disagree on whether Access Control Lists affect the result: some base the test on the traditional owner/group/other mode bits, while others base it on whether the user actually has the right through an ACL . A passing -w test does not guarantee that a subsequent write will succeed.

The check-then-use race problem. Any sequence of [[ -f "$file" ]] && cat "$file" has a window between the test and the cat during which the file can be deleted, replaced, or have its permissions changed. The Autoconf manual is explicit about this: “there is a classic time of check to time of use race between any use of test followed by operating on the just-checked file” . The robust approach is to attempt the operation and handle the failure, rather than to rely solely on the test.

The symlink versus target problem. Unless otherwise specified, the file primaries follow symbolic links and operate on the target of the link . This means -f "$link" is true if the link points to a regular file, even though the link itself is not a regular file. The -L and -h operators test the link itself. A broken link passes -L but fails -e, because there is no target .


a. Existence and type operators

The -e operator tests whether anything exists at the path. The type operators narrow that to a specific kind of file. The operators are used inside [[ ]] or with the test command.

# Check existence
if [[ -e "$path" ]]; then
    echo "exists"
fi

# Check for a regular file
if [[ -f "$path" ]]; then
    echo "regular file"
fi

# Check for a directory
if [[ -d "$path" ]]; then
    echo "directory"
fi

The type operators include -b for block special files, -c for character special files, -p for named pipes (FIFOs), -S for sockets, and -L or -h for symbolic links . A single path can be tested against multiple operators in a sequence to classify it.

if [[ -L "$path" ]]; then
    echo "symbolic link"
elif [[ -f "$path" ]]; then
    echo "regular file"
elif [[ -d "$path" ]]; then
    echo "directory"
elif [[ -p "$path" ]]; then
    echo "named pipe"
fi

The -L test should come first when classifying, because -f and -d follow the link and would report the target’s type for a symlink .

b. Permission operators and their limits

The -r, -w, and -x operators test readability, writability, and executability. They are the most commonly used file tests and the most commonly misunderstood.

if [[ -r "$file" ]]; then
    echo "readable"
fi

if [[ -w "$file" ]]; then
    echo "writable"
fi

if [[ -x "$file" ]]; then
    echo "executable"
fi

The root-user exception is the first limit. For root, -x can return true even when no execute bit is set, because root is permitted to execute files regardless of the mode . A script that uses -x to decide whether to run a file may attempt to run a non-executable file when run as root. The second limit is the ACL ambiguity: some shells consult the mode bits only, while others consult the actual access rights including ACLs . The third limit is the race: a file can be replaced between the test and the operation.

The practical consequence is that permission tests are useful for producing clear error messages, but they are not a guarantee. A script that must write to a file should attempt the write and handle failure, rather than gating the write on -w.

c. Attribute operators

The -s operator tests whether a file exists and has a size greater than zero. It is commonly used to check that a file is not empty before processing it.

if [[ -s "$file" ]]; then
    echo "file has content"
fi

The -O and -G operators test whether the file is owned by the effective user ID or effective group ID, respectively . The -N operator tests whether the file has been modified since it was last read, which is useful for detecting changes in a file the script has previously processed .

The -nt and -ot binary operators compare modification times: file1 -nt file2 is true if file1 is newer, and file1 -ot file2 is true if file1 is older . The -ef operator tests whether two paths refer to the same device and inode, which identifies hard links and duplicate paths .


Complete Example Session

# ============================================
# PART 1: BASIC EXISTENCE CHECK
# ============================================
path="/etc/passwd"

if [[ -e "$path" ]]; then
    echo "Path exists"
fi

# -e is true for any type of file.
# ============================================
# PART 2: TYPE CLASSIFICATION
# ============================================
classify() {
    local p=$1
    if [[ -L "$p" ]]; then
        echo "symlink"
    elif [[ -f "$p" ]]; then
        echo "regular file"
    elif [[ -d "$p" ]]; then
        echo "directory"
    elif [[ -p "$p" ]]; then
        echo "fifo"
    elif [[ -S "$p" ]]; then
        echo "socket"
    elif [[ -b "$p" ]]; then
        echo "block device"
    elif [[ -c "$p" ]]; then
        echo "character device"
    else
        echo "unknown"
    fi
}

classify /etc/passwd   # regular file
classify /tmp          # directory
# ============================================
# PART 3: SYMLINK VERSUS TARGET
# ============================================
ln -sf /etc/passwd /tmp/passwd-link

[[ -L /tmp/passwd-link ]] && echo "is a symlink"
[[ -f /tmp/passwd-link ]] && echo "target is a regular file"
[[ -e /tmp/passwd-link ]] && echo "target exists"

# -L tests the link.
# -f and -e follow the link and test the target.
# ============================================
# PART 4: BROKEN SYMLINK
# ============================================
ln -sf /nonexistent /tmp/broken-link

[[ -L /tmp/broken-link ]] && echo "link exists"
[[ ! -e /tmp/broken-link ]] && echo "target does not exist"

# -L true, -e false: a broken link.
# ============================================
# PART 5: PERMISSION TESTS
# ============================================
file="/etc/shadow"

[[ -r "$file" ]] && echo "readable" || echo "not readable"
[[ -w "$file" ]] && echo "writable" || echo "not writable"
[[ -x "$file" ]] && echo "executable" || echo "not executable"

# As non-root, /etc/shadow is typically not readable.
# ============================================
# PART 6: THE ROOT EXCEPTION
# ============================================
touch /tmp/nonexec
chmod 644 /tmp/nonexec

# As non-root:
[[ -x /tmp/nonexec ]] || echo "not executable"

# As root, -x may still return false for a file
# with no execute bits, but root can execute it.
# The test reflects the mode, not the ability.
# ============================================
# PART 7: EMPTY FILE CHECK
# ============================================
touch /tmp/empty
echo "data" > /tmp/notempty

[[ -s /tmp/empty ]] || echo "empty file"
[[ -s /tmp/notempty ]] && echo "file has content"
# ============================================
# PART 8: OWNERSHIP TESTS
# ============================================
[[ -O /etc/passwd ]] && echo "owned by effective user"
[[ -G /etc/passwd ]] && echo "owned by effective group"

# Usually root owns /etc/passwd.
# ============================================
# PART 9: COMPARISON OPERATORS
# ============================================
touch /tmp/old /tmp/new
sleep 1
touch /tmp/new

[[ /tmp/new -nt /tmp/old ]] && echo "new is newer"
[[ /tmp/old -ot /tmp/new ]] && echo "old is older"

ln /tmp/new /tmp/hardlink
[[ /tmp/new -ef /tmp/hardlink ]] && echo "same inode"
# ============================================
# PART 10: THE CHECK-THEN-USE RACE
# ============================================
# The test is not a guarantee.
# Between the check and the operation, the file can change.

# Instead of:
# [[ -f "$file" ]] && cat "$file"

# Prefer:
if ! cat "$file" 2>/dev/null; then
    echo "Could not read file" >&2
    exit 1
fi

# The operation handles failure directly.

The ten parts covered existence, type classification, symlink versus target, broken links, permissions, the root exception, empty files, ownership, comparison operators, and the race condition.


Quick Reference

Existence and type operators

OperatorTrue when
-e fileAnything exists at path
-f fileRegular file exists
-d fileDirectory exists
-L file / -h fileSymbolic link exists
-b fileBlock special file
-c fileCharacter special file
-p fileNamed pipe (FIFO)
-S fileSocket

Permission and attribute operators

OperatorTrue when
-r fileReadable by current user
-w fileWritable by current user
-x fileExecutable by current user
-s fileExists and size > 0
-O fileOwned by effective user ID
-G fileOwned by effective group ID
-N fileModified since last read

Comparison operators

OperatorTrue when
file1 -nt file2file1 newer than file2
file1 -ot file2file1 older than file2
file1 -ef file2Same device and inode

Symlink behavior

TestSymlinkBroken symlink
-LTrueTrue
-eTrue (target)False
-fTrue if target is fileFalse

Best Practices

โœ… Do This:

# Test the link itself with -L before testing the target
[[ -L "$path" ]] && echo "link"                         # โœ…
# Use -e for existence, not -f, when any type is acceptable
[[ -e "$path" ]] && echo "exists"                       # โœ…
# Use -s to check for empty files
[[ -s "$file" ]] || echo "empty"                        # โœ…
# Handle operation failure directly instead of relying on -r/-w
cat "$file" || { echo "read failed"; exit 1; }          # โœ…

โŒ Don’t Do This:

# Assume -f tests a symlink itself
[[ -f "$link" ]] && echo "link"                         # โŒ (tests target)
# Assume -x guarantees execution as root
[[ -x "$file" ]] && ./"$file"                           # โŒ (root exception)
# Rely solely on -w before writing
[[ -w "$file" ]] && echo data > "$file"                 # โŒ (race condition)
# Use -e when you specifically need a regular file
[[ -e "$path" ]] && cat "$path"                         # โŒ (may be a directory)

Common Pitfalls

PitfallWhy It HappensFix
-f true for a symlinkFile tests follow linksUse -L to test the link itself
Broken link not detected-e follows the linkTest -L and ! -e together
-x true for non-executable as rootRoot can execute any fileAccept the exception; do not rely on -x alone
Write fails despite -wACL or race conditionHandle the write failure directly
Directory treated as file-e true for directoriesUse -f when a regular file is required

Real-World Examples

1. Check if a file exists

[[ -e "$path" ]] && echo "exists"

2. Check for a regular file

[[ -f "$path" ]] && echo "regular file"

3. Check for a directory

[[ -d "$path" ]] && echo "directory"

4. Check for a symlink

[[ -L "$path" ]] && echo "symlink"

5. Check readability

[[ -r "$file" ]] && echo "readable"

6. Check for empty file

[[ -s "$file" ]] || echo "empty"

7. Check ownership

[[ -O "$file" ]] && echo "owned by me"

8. Compare modification times

[[ "$new" -nt "$old" ]] && echo "newer"

9. Check same inode

[[ "$a" -ef "$b" ]] && echo "same file"

10. Safe read with error handling

cat "$file" 2>/dev/null || { echo "read failed" >&2; exit 1; }

Visual

Symlink testing flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  TESTING A SYMLINK                                           โ”‚
โ”‚                                                              โ”‚
โ”‚  Path: /tmp/link โ”€โ”€โ–บ /etc/passwd                             โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ -L /tmp/link ]]                                          โ”‚
โ”‚    Tests the link itself โ”€โ”€โ–บ true                            โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ -e /tmp/link ]]                                          โ”‚
โ”‚    Follows link to target โ”€โ”€โ–บ true (passwd exists)           โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ -f /tmp/link ]]                                          โ”‚
โ”‚    Follows link to target โ”€โ”€โ–บ true (passwd is regular)       โ”‚
โ”‚                                                              โ”‚
โ”‚  Broken link: /tmp/broken โ”€โ”€โ–บ /nonexistent                   โ”‚
โ”‚    -L: true    (link exists)                                 โ”‚
โ”‚    -e: false   (target missing)                              โ”‚
โ”‚    -f: false   (no target)                                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Operator categories

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  FILE TEST OPERATORS                                         โ”‚
โ”‚                                                              โ”‚
โ”‚  EXISTENCE                                                   โ”‚
โ”‚    -e   anything exists                                      โ”‚
โ”‚                                                              โ”‚
โ”‚  TYPE                                                        โ”‚
โ”‚    -f   regular file                                         โ”‚
โ”‚    -d   directory                                            โ”‚
โ”‚    -L   symlink                                              โ”‚
โ”‚    -b   block device                                         โ”‚
โ”‚    -c   character device                                     โ”‚
โ”‚    -p   FIFO                                                 โ”‚
โ”‚    -S   socket                                               โ”‚
โ”‚                                                              โ”‚
โ”‚  PERMISSION                                                  โ”‚
โ”‚    -r   readable                                             โ”‚
โ”‚    -w   writable                                             โ”‚
โ”‚    -x   executable                                           โ”‚
โ”‚                                                              โ”‚
โ”‚  ATTRIBUTE                                                   โ”‚
โ”‚    -s   size > 0                                             โ”‚
โ”‚    -O   owned by effective user                              โ”‚
โ”‚    -G   owned by effective group                             โ”‚
โ”‚    -N   modified since read                                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The check-then-use race

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  TIME OF CHECK TO TIME OF USE                                โ”‚
โ”‚                                                              โ”‚
โ”‚  Time โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ            โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ -f "$file" ]]  โ”€โ”€โ”€โ”€โ”€โ”€โ–บ  cat "$file"                     โ”‚
โ”‚       โ”‚                         โ”‚                            โ”‚
โ”‚       โ”‚                         โ”‚                            โ”‚
โ”‚       โ–ผ                         โ–ผ                            โ”‚
โ”‚  File exists                File may be gone,                โ”‚
โ”‚  (test passes)              replaced, or changed             โ”‚
โ”‚                                                              โ”‚
โ”‚  The gap between the test and the operation is the race.     โ”‚
โ”‚  The test does not guarantee the operation.                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Root exception for -x

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ROOT AND -x                                                 โ”‚
โ”‚                                                              โ”‚
โ”‚  File: /tmp/nonexec                                          โ”‚
โ”‚  Mode: 644 (no execute bits)                                 โ”‚
โ”‚                                                              โ”‚
โ”‚  As regular user:                                            โ”‚
โ”‚    [[ -x /tmp/nonexec ]] โ”€โ”€โ–บ false                           โ”‚
โ”‚                                                              โ”‚
โ”‚  As root:                                                    โ”‚
โ”‚    [[ -x /tmp/nonexec ]] โ”€โ”€โ–บ may be false (mode-based)       โ”‚
โ”‚    But root can execute the file anyway.                     โ”‚
โ”‚                                                              โ”‚
โ”‚  POSIX allows -x to succeed for root even when the           โ”‚
โ”‚  execute bits are clear. The test reflects the mode,         โ”‚
โ”‚  not the kernel's permission to execute.                     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Existence-e
Regular file-f
Directory-d
Symbolic link-L or -h
Readable-r
Writable-w
Executable-x
Non-empty-s
Owned by user-O
Newer than-nt
Same inode-ef
Symlink default behaviorFollows link to target

Key takeaways:

  • The file-testing operators follow symbolic links by default. -f "$link" tests the target, not the link. Use -L to test the link itself. A broken link passes -L but fails -e .
  • Permission tests have exceptions. -x can succeed for root even without execute bits, and -r/-w may or may not account for ACLs depending on the shell . A passing test is a hint, not a guarantee.
  • There is a race between the test and the operation. The Autoconf manual recommends attempting the operation and handling failure, rather than relying solely on a preceding test .
  • -e is true for any type of file. A directory, a device, and a regular file all pass -e. Use -f when a regular file is required.
  • -s checks for non-empty files. It is true when the file exists and has a size greater than zero. A zero-byte file fails -s but passes -e .
  • The comparison operators compare metadata. -nt and -ot compare modification times. -ef compares device and inode, which identifies hard links and duplicate paths .

Remember: File testing in Bash answers three questions: does it exist, what is it, and can I access it. The type operators (-f, -d, -L, and the special-file operators) are reliable classifications. The permission operators (-r, -w, -x) are useful for error messages but unreliable as gates, because of the root exception, ACL ambiguity, and the check-then-use race. Test the type, attempt the operation, and handle the failure. The one operator that catches the most scripts is -L: a symlink to a regular file passes -f, and a broken symlink passes -L but fails -e.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!