Skip to main content
Bash beginner Lesson 10 of 22

Bash File Operations

Use file test operators, safely copy, move, and delete files, and work with find and stat in Bash scripts.

File Test Operators

Before your script acts on a file, it should verify that the file exists, has the right type, and has the permissions needed. Skipping these checks leads to cryptic error messages from the tool that fails rather than clear messages from your script. File test operators are the guard clause mechanism for filesystem operations.

path="/etc/nginx/nginx.conf"

[[ -e "$path" ]]  && echo "exists"           # any filesystem entry (file, dir, symlink)
[[ -f "$path" ]]  && echo "regular file"     # not a directory or symlink
[[ -d "$path" ]]  && echo "directory"
[[ -L "$path" ]]  && echo "symbolic link"
[[ -r "$path" ]]  && echo "readable"
[[ -w "$path" ]]  && echo "writable"
[[ -x "$path" ]]  && echo "executable"
[[ -s "$path" ]]  && echo "non-empty (size > 0)"
[[ -z "$path" ]]  && echo "path variable is empty string"

Comparing two files:

[[ "file1" -nt "file2" ]]  && echo "file1 is newer"   # newer than
[[ "file1" -ot "file2" ]]  && echo "file1 is older"   # older than
[[ "file1" -ef "file2" ]]  && echo "same inode"        # hard link or same file

A practical guard function that validates a config file before the script tries to use it:

require_file() {
  local file="$1"
  if [[ ! -f "$file" ]]; then
    echo "ERROR: required file not found: $file" >&2
    exit 1
  fi
  if [[ ! -r "$file" ]]; then
    echo "ERROR: file not readable: $file" >&2
    exit 1
  fi
}

require_file "/etc/myapp/config.yml"

Reading Files

Reading file content correctly in Bash requires the right idiom. The while IFS= read -r pattern is the standard — it preserves whitespace exactly and handles backslashes in content without interpreting them.

# Line by line — the correct and safe way
while IFS= read -r line; do
  echo ">> $line"
done < /etc/hosts

# Read entire file content into a variable
content=$(<"/etc/hostname")
echo "Hostname: $content"

# Read all lines into an array (one element per line)
mapfile -t lines < /etc/resolv.conf
echo "DNS config has ${#lines[@]} lines"

Copying Files

cp has flags for every common scenario. The key habits are: preserve metadata when it matters, create destination directories proactively, and validate sources before acting.

# Basic copy
cp source.txt destination.txt

# Copy preserving timestamps, permissions, and ownership
cp -p source.txt destination.txt

# Copy a directory recursively
cp -r /src/dir /dst/dir

# Copy only if source is newer than destination
cp -u source.txt destination.txt

# Verbose — print each file as it is copied
cp -v *.log /backup/

# Safe copy: ensure the destination directory exists first
copy_safe() {
  local src="$1"
  local dst="$2"
  local dst_dir
  dst_dir=$(dirname "$dst")

  if [[ ! -f "$src" ]]; then
    echo "ERROR: source not found: $src" >&2
    return 1
  fi

  mkdir -p "$dst_dir"          # create destination dir if needed
  cp -p "$src" "$dst"          # copy with metadata preserved
}

Moving and Renaming Files

mv is both a rename (when source and destination are on the same filesystem) and a move. Within the same filesystem, it is nearly instant — just a directory entry update. Across filesystems, it copies then deletes.

# Rename a file
mv old_name.txt new_name.txt

# Move to a different directory
mv report.csv /archive/2025/

# Move multiple files at once
mv *.log /var/log/archive/

# Safe move — prompt before overwriting an existing file
mv -i important.txt /backup/

# No-clobber — skip silently if destination already exists
mv -n source.txt destination.txt

# Bulk rename with a loop — change .JPG to .jpg
for f in *.JPG; do
  mv "$f" "${f%.JPG}.jpg"   # ${f%.JPG} strips the .JPG suffix
done

Deleting Files Safely

rm -rf is one of the most dangerous commands in Unix. A misquoted variable or an accidental empty string can delete far more than intended. Defensive patterns make deletion safer.

# Basic remove
rm file.txt

# Remove without error if file does not exist
rm -f file.txt

# Remove directory and all contents
rm -rf /tmp/work_dir/

# SAFER: move to a trash directory instead of deleting immediately
safe_delete() {
  local target="$1"
  local trash_dir="/tmp/trash/$$"   # $$ is the PID — unique per script run
  mkdir -p "$trash_dir"
  mv "$target" "$trash_dir/"
  echo "Moved to trash: $trash_dir/$(basename "$target")"
}

# The most dangerous pattern — never do this:
# rm -rf $dir/   ← if $dir is empty, this becomes: rm -rf /

# Always guard with :? to error out if the variable is empty:
[[ -n "$dir" ]] && rm -rf "${dir:?}/"
# ${dir:?} causes the script to exit immediately if $dir is empty or unset

Creating Directories

mkdir -p is the workhorse for directory creation in scripts — it creates all intermediate directories and does not error if the directory already exists, making it safe to call unconditionally.

# Create a single directory
mkdir /tmp/myapp

# Create with parents — no error if any intermediate directory already exists
mkdir -p /tmp/myapp/config/subdir

# Create with specific permissions
mkdir -m 700 /tmp/secrets   # only the owner can access

# Create a temporary directory with a unique name
tmpdir=$(mktemp -d)
echo "Working in: $tmpdir"
# ... do work ...
rm -rf "$tmpdir"   # clean up when done

find — Searching the Filesystem

find searches the filesystem recursively with powerful filters. It is the right tool whenever you need to locate files by name, age, size, or permissions, and then act on the results.

# Find by name
find /var/log -name "*.log"
find /home -name "*.bashrc" -type f

# Find by type
find /etc -type f    # regular files only
find /etc -type d    # directories only
find /etc -type l    # symbolic links only

# Find by size
find /var -size +100M          # larger than 100 MB
find /tmp -size -1k            # smaller than 1 KB
find /home -size +10M -size -100M   # between 10M and 100M

# Find by modification time
find /var/log -mtime +7    # modified more than 7 days ago
find /tmp -mtime -1        # modified within the last 24 hours
find /home -newer /tmp/reference_file   # newer than a reference file

# Execute a command on each result
find /var/log/nginx -name "*.log" -exec gzip {} \;
find /tmp -name "*.tmp" -mtime +1 -delete   # delete old temp files

# Safety: use -print0 and xargs -0 for filenames that may contain spaces
find /home -name "*.txt" -print0 | xargs -0 grep -l "TODO"

stat — File Metadata

stat shows detailed metadata about a file — size, permissions, timestamps, and inode information. In scripts it is most useful for getting the modification time or size as a number for comparisons and cache invalidation logic.

stat /etc/passwd
# Output:
#   File: /etc/passwd
#   Size: 2847      Blocks: 8    IO Block: 4096  regular file
# Device: fd00h/64768d  Inode: 131073  Links: 1
# Access: (0644/-rw-r--r--)  Uid: ( 0/ root)  Gid: ( 0/ root)
# Modify: 2025-01-01 10:00:00.000

# Get specific fields with -c format (Linux stat)
stat -c "%n %s %Y" /etc/passwd    # name, size in bytes, modification timestamp

# Get file size in bytes
filesize=$(stat -c '%s' "$filename")
echo "Size: $filesize bytes"

# Check modification time for cache invalidation
last_modified=$(stat -c '%Y' config.yml)   # Unix timestamp
now=$(date +%s)
age=$(( now - last_modified ))
if (( age > 3600 )); then
  echo "Config is stale — reload"
fi

Symlinks decouple a name from the actual file location. They are widely used in deployment scripts (a current symlink that points to the latest release) and in managing multiple versions of tools.

# Create a symlink
ln -s /actual/path /link/path

# Overwrite an existing symlink atomically
ln -sf /new/target /link/path

# Resolve a symlink to its real path
realpath /usr/bin/python
# /usr/bin/python3.12

# Get the real directory of the running script, even if called via a symlink
SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)"

Atomic File Writes

Writing a file in production scripts carries a risk: if the process is interrupted mid-write, the file is left in a partial state. The atomic write pattern solves this by writing to a temp file first and then renaming it — mv within the same filesystem is a single atomic kernel operation.

write_config() {
  local target="$1"
  local tmpfile
  # Create temp file in same directory as target — same filesystem guaranteed
  tmpfile=$(mktemp "${target}.XXXXXX")

  # Write to the temp file
  cat > "$tmpfile" <<EOF
host=localhost
port=5432
EOF

  # Validate before replacing the live file
  if grep -q "host=" "$tmpfile"; then
    mv "$tmpfile" "$target"   # atomic rename — readers see old or new, never partial
    echo "Config updated: $target"
  else
    rm -f "$tmpfile"
    echo "ERROR: validation failed, config not updated" >&2
    return 1
  fi
}

What’s Next

The next tutorial covers input and output — reading from stdin, formatting output with printf, redirection, pipes, and tee.

Frequently Asked Questions

How do I check if a file exists in Bash?
Use [[ -f "$path" ]] for a regular file or [[ -e "$path" ]] for any filesystem entry (file, directory, symlink). Always quote the path variable.
How do I safely delete files in a script?
Use rm with -f to suppress errors on missing files, always quote paths, use set -euo pipefail to catch failures, and consider moving to a trash directory instead of deleting immediately for critical data.
What is the difference between -f and -e test operators?
-e returns true if the path exists (file, directory, symlink, etc.). -f returns true only if it's a regular file (not a directory or symlink).