← Back to DevBytes

Zsh Scripting: File Operations Complete Guide

Introduction to Zsh File Operations

Zsh (Z shell) is a powerful shell that extends traditional Bash scripting with richer globbing, better parameter expansion, and a more expressive syntax. File operations are among the most common tasks you'll perform in shell scripts — from reading configuration files to batch-renaming thousands of assets. This guide walks you through everything you need to know to handle files confidently in Zsh.

What Is Zsh File Scripting?

Zsh file scripting refers to writing scripts (files with a .zsh extension or any executable script using the #!/usr/bin/env zsh shebang) that create, read, modify, move, copy, and delete files. Because Zsh is a superset of most POSIX shell features, scripts written for Bash often work in Zsh — but Zsh adds unique features like extended globbing, qualifier-based file matching, and associative arrays that make file manipulation cleaner.

Why It Matters

Setting Up Your Environment

Before writing scripts, ensure Zsh is installed and enable its extended features. Create a script file and make it executable.

#!/usr/bin/env zsh
# Enable Zsh-specific features
setopt EXTENDED_GLOB
setopt NULL_GLOB
setopt ERR_EXIT

echo "Zsh file scripting ready"

Save this as setup.zsh, then run chmod +x setup.zsh and execute it with ./setup.zsh. The options used here are:

Checking File Existence and Type

The most fundamental file operation is testing whether a file exists and what type it is. Zsh supports the same conditional operators as Bash.

#!/usr/bin/env zsh

filepath="$HOME/.zshrc"

if [[ -e "$filepath" ]]; then
  echo "Exists: $filepath"
fi

if [[ -f "$filepath" ]]; then
  echo "It is a regular file"
fi

if [[ -d "$filepath" ]]; then
  echo "It is a directory"
fi

if [[ -r "$filepath" && -w "$filepath" ]]; then
  echo "Readable and writable"
fi

if [[ -s "$filepath" ]]; then
  echo "File is non-empty"
fi

Common test operators include -e (exists), -f (regular file), -d (directory), -r (readable), -w (writable), -x (executable), and -s (non-empty size).

Creating Files and Directories

Creating files and directories is straightforward with touch and mkdir. Use the -p flag with mkdir to create parent directories as needed.

#!/usr/bin/env zsh

# Create a single file
touch "$HOME/notes.txt"

# Create nested directories
mkdir -p "$HOME/projects/zsh-scripts/utils"

# Create multiple files at once
touch file1.txt file2.txt file3.txt

# Create a file with initial content using a here-doc
cat << 'EOF' > "$HOME/projects/zsh-scripts/README.md"
# Zsh Scripts

A collection of utility scripts.
EOF

Reading File Contents

There are several ways to read a file in Zsh. The simplest is cat, but for line-by-line processing, a while read loop is more memory-efficient.

Reading the Entire File

#!/usr/bin/env zsh

content=$(< "$HOME/notes.txt")
echo "$content"

The $(< file) syntax is a Zsh/Bash shortcut that reads the entire file content into a variable without spawning a subshell for cat.

Reading Line by Line

#!/usr/bin/env zsh

while IFS= read -r line; do
  echo "Line: $line"
done < "$HOME/notes.txt"

The IFS= prevents trimming of leading/trailing whitespace, and -r prevents backslash interpretation. Always quote your variables to handle filenames with spaces.

Reading Into an Array

#!/usr/bin/env zsh

# Split file into array of lines
lines=("${(@f)$(< "$HOME/notes.txt")}")

for ((i = 1; i <= ${#lines[@]}; i++)); do
  echo "Line $i: ${lines[$i]}"
done

The (@f) flag splits the content on newlines. Note that Zsh arrays are 1-indexed, unlike Bash which is 0-indexed.

Writing and Appending to Files

Use output redirection to write or append to files. The > operator overwrites, while >> appends.

#!/usr/bin/env zsh

logfile="$HOME/app.log"

# Overwrite
echo "=== Session started ===" > "$logfile"

# Append
echo "$(date): Doing something" >> "$logfile"
echo "$(date): Doing something else" >> "$logfile"

# Write multi-line content
cat > "$HOME/config.conf" << EOF
host=localhost
port=8080
debug=true
EOF

Copying, Moving, and Renaming Files

The cp and mv commands handle copying and moving. Always use quotes around paths to handle spaces safely.

#!/usr/bin/env zsh

src="$HOME/notes.txt"
dst="$HOME/notes_backup.txt"

# Copy a file
cp "$src" "$dst"

# Copy and preserve attributes (timestamps, permissions)
cp -p "$src" "${src}.preserved"

# Copy a directory recursively
cp -r "$HOME/projects" "$HOME/projects_backup"

# Move/rename a file
mv "$dst" "$HOME/notes_renamed.txt"

# Move only if destination doesn't exist (prevent overwrite)
mv -n "$HOME/notes_renamed.txt" "$HOME/notes_final.txt"

Deleting Files and Directories

Deletion is permanent, so always validate paths before removing. The rm command removes files, and -r enables recursive deletion for directories.

#!/usr/bin/env zsh

setopt ERR_EXIT

target="$HOME/old_temp_file.txt"

if [[ -f "$target" ]]; then
  rm "$target"
  echo "Removed $target"
else
  echo "File not found, skipping"
fi

# Remove an empty directory
rmdir "$HOME/empty_dir"

# Remove a directory and its contents
rm -r "$HOME/old_project"

# Safer interactive removal (prompts for confirmation)
rm -ri "$HOME/unsure_dir"

Zsh Extended Globbing for File Matching

This is where Zsh truly shines. With extended globbing enabled, you can match files with incredible precision using glob qualifiers.

Basic Extended Glob Patterns

#!/usr/bin/env zsh
setopt EXTENDED_GLOB

# All .txt files except README.txt
ls *.txt~README.txt

# Files starting with 'test' followed by any number of digits
ls test[0-9]##

# Files that are NOT .log files
ls ^*.log

Glob Qualifiers

Glob qualifiers let you filter by file attributes. They appear in parentheses at the end of a pattern.

#!/usr/bin/env zsh
setopt EXTENDED_GLOB

# Only regular files
ls *(.)

# Only directories
ls *(/)

# Only executable files
ls *(*)

# Files modified in the last 3 days
ls *(.m-3)

# Files larger than 1MB
ls *(.Lm+1)

# Sort by modification time, newest first
ls *(.om[1,5])

# The 5 most recently modified files
newest=(*(.om[1,5]))
print -l $newest

Qualifier breakdown: . means regular file, / means directory, * means executable, m-3 means modified within 3 days, Lm+1 means size greater than 1 megabyte, om sorts by modification time, and [1,5] limits to the first 5 results.

Batch Renaming Files

Zsh's zmv function is a powerful tool for batch renaming. Load it with autoload -U zmv.

#!/usr/bin/env zsh
autoload -U zmv

# Rename all .txt files to .md
zmv '(*).txt' '$1.md'

# Lowercase all filenames in the current directory
zmv '(*)' '${1:l}'

# Add a prefix to all .jpg files
zmv '(*).jpg' 'vacation_$1.jpg'

# Replace spaces with underscores
zmv '* *' '${1// /_}'

# Use -n for a dry run (preview without changes)
zmv -n '(*).jpeg' '$1.jpg'

Always run zmv -n first to preview the changes. The ${1:l} syntax lowercases the captured group, and ${1// /_} replaces all spaces with underscores.

Working with File Permissions

Use chmod to change permissions and stat to inspect them.

#!/usr/bin/env zsh

script="$HOME/myscript.zsh"

# Make a script executable
chmod +x "$script"

# Set specific permissions (owner: rwx, group: r-x, others: r--)
chmod 754 "$script"

# Recursively set directory permissions to 755
find "$HOME/projects" -type d -exec chmod 755 {} +

# Recursively set file permissions to 644
find "$HOME/projects" -type f -exec chmod 644 {} +

Finding Files

While Zsh globbing handles most cases, find is useful for complex searches across directory trees.

#!/usr/bin/env zsh

# Find all .log files modified in the last 7 days
find "$HOME" -name "*.log" -mtime -7 -print

# Find and delete empty files
find "$HOME/tmp" -type f -empty -delete

# Find files larger than 100MB
find "$HOME" -type f -size +100M -print

# Find files matching a pattern and process them
while IFS= read -r file; do
  echo "Processing: $file"
done < <(find "$HOME/projects" -name "*.zsh" -type f)

Archiving and Compressing Files

Use tar to create archives and gzip or xz for compression.

#!/usr/bin/env zsh

project_dir="$HOME/projects/zsh-scripts"
archive="$HOME/zsh-scripts-$(date +%Y%m%d).tar.gz"

# Create a compressed tarball
tar -czf "$archive" -C "$(dirname "$project_dir")" "$(basename "$project_dir")"
echo "Created archive: $archive"

# Extract a tarball
tar -xzf "$archive" -C "$HOME/restored"

# List contents without extracting
tar -tzf "$archive"

Handling Filenames with Spaces and Special Characters

Filenames with spaces, newlines, or special characters can break naive scripts. Always quote variables and use null-delimited output when possible.

#!/usr/bin/env zsh

# Safe iteration using null delimiter
find "$HOME" -name "*.txt" -print0 | while IFS= read -r -d '' file; do
  echo "Found: $file"
done

# Using Zsh arrays with proper quoting
files=("$HOME"/*.txt)
for f in "${files[@]}"; do
  echo "Processing: $f"
done

Best Practices

Putting It All Together: A Backup Script

Here is a complete script that combines many of the techniques covered in this guide.

#!/usr/bin/env zsh
setopt ERR_EXIT
setopt EXTENDED_GLOB
setopt NULL_GLOB

# Configuration
source_dir="$HOME/projects"
backup_dir="$HOME/backups"
logfile="$backup_dir/backup.log"
timestamp=$(date +%Y%m%d_%H%M%S)
archive="$backup_dir/backup_${timestamp}.tar.gz"

# Ensure backup directory exists
mkdir -p "$backup_dir"

# Log helper
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$logfile"
}

log "Starting backup of $source_dir"

if [[ ! -d "$source_dir" ]]; then
  log "ERROR: Source directory does not exist"
  exit 1
fi

# Create the archive
tar -czf "$archive" -C "$HOME" "$(basename "$source_dir")"
log "Created archive: $archive"

# Remove backups older than 30 days
old_backups=("$backup_dir"/backup_*.tar.gz(.m+30))
if (( ${#old_backups[@]} > 0 )); then
  rm "${old_backups[@]}"
  log "Removed ${#old_backups[@]} old backup(s)"
fi

# List remaining backups
log "Current backups:"
for f in "$backup_dir"/backup_*.tar.gz; do
  log "  - $(basename "$f") ($(stat -f%z "$f" 2>/dev/null || stat -c%s "$f") bytes)"
done

log "Backup complete"

This script creates a timestamped archive of your projects directory, logs every action, prunes backups older than 30 days using Zsh glob qualifiers, and lists the remaining backups with their sizes. The (.m+30) qualifier selects regular files modified more than 30 days ago.

Conclusion

Zsh is an exceptional tool for file operations, blending POSIX compatibility with powerful extensions like glob qualifiers, zmv, and rich parameter expansion. By mastering the techniques in this guide — from basic existence checks to advanced batch renaming and safe handling of special characters — you can write robust, readable scripts that automate file management with confidence. Start small, always test with dry runs, and gradually incorporate best practices like ERR_EXIT and input validation into every script you write. With these foundations, you'll be well-equipped to tackle any file operation challenge Zsh throws your way.

— Ad —

Google AdSense will appear here after approval

← Back to all articles