Back to Guides
April 22, 20268 minutes
Zsh Scripting Cheatsheet for Advanced Automation
A practical guide to zsh scripting covering parameter expansion, globbing, process substitution, and advanced patterns you actually need.
zshscriptingshellautomationcli
Zsh Scripting Cheatsheet for Advanced Automation
Zsh isn't just a better-looking bash. Once you understand its advanced features, you can write scripts that are shorter, safer, and more expressive. This guide covers the patterns I use daily for automation, devops tooling, and CLI utilities.
Start Every Script Right
#!/usr/bin/env zsh
set -euo pipefail
set -e: Exit immediately if a command failsset -u: Treat unset variables as errorsset -o pipefail: Pipeline fails if any command fails, not just the last
Parameter Expansion: The Real Power
Default Values
# Use default if unset or empty
name=${1:-"world"}
# Use default only if unset (empty string is valid)
name=${1-"world"}
# Set default if unset
: ${CONFIG_FILE:=~/.config/myapp/config.yaml}
String Manipulation
path="/home/user/documents/file.txt"
# Remove shortest match from end
${path%.*} # /home/user/documents/file
# Remove longest match from end
${path%%/*} # (empty - removes everything)
${path##*/} # file.txt
# Remove shortest match from beginning
${path#*/} # home/user/documents/file.txt
# Remove longest match from beginning
${path##*/} # file.txt
# Replace first occurrence
${path/user/admin} # /home/admin/documents/file.txt
# Replace all occurrences
${path//\//-} # -home-user-documents-file.txt
Variable Inspection
var="hello world"
${#var} # 11 (length)
${var:0:5} # hello (substring)
${var:6} # world (from index 6 to end)
${var[-1]} # d (last character, zsh-only)
${var[1,5]} # hello (zsh-style slice)
Arrays: Zsh Does Them Right
# Define arrays
files=(*.txt)
hosts=(web1 web2 db1 db2)
# Append
hosts+=(cache1)
# Access elements
${hosts[1]} # web1 (zsh is 1-indexed!)
${hosts[-1]} # cache1 (last element)
${hosts[2,4]} # web2 db1 db2 (slice)
# All elements
${hosts[@]} # web1 web2 db1 db2 cache1
${#hosts[@]} # 5 (count)
# Iterate
for host in $hosts; do
ssh $host "uptime"
done
# Join with delimiter
local IFS=,
hosts_string="${hosts[*]}" # web1,web2,db1,db2,cache1
Globbing: Pattern Matching on Steroids
# Recursive glob (no need for find)
ls **/*.py
# Case-insensitive glob
ls (#i)*.txt # matches FILE.TXT, File.txt, etc.
# Glob qualifiers (most powerful feature)
ls -l *(.) # regular files only
ls -l *(@) # symbolic links
ls -l *(/) # directories only
ls -l *(*W) # world-writable files
# Sort by modification time, get 5 most recent
ls -lt *(om[1,5])
# Files larger than 1MB
ls -lh *(.LM+1)
# Empty directories
rmdir **/*(/Dod)
Glob Qualifiers Reference
| Qualifier | Meaning |
|---|---|
. | Regular files |
/ | Directories |
@ | Symbolic links |
* | Executable files |
r | Readable by owner |
w | Writable by owner |
x | Executable by owner |
R | Readable by world |
W | Writable by world |
X | Executable by world |
L+size | Larger than size (in bytes, or use k/M/G) |
Lm+1 | Larger than 1MB |
om | Sort by modification time (oldest first) |
Om | Sort by modification time (newest first) |
[1,5] | Only first 5 matches |
D | Include dotfiles |
Process Substitution
# Compare two command outputs without temp files
diff <(sort file1.txt) <(sort file2.txt)
# Feed multiple outputs to a command
cat <(echo "Header") <(tail -n +2 data.csv) > output.csv
# Process a file while reading it
while read line; do
echo "Processing: $line"
done < <(grep "ERROR" app.log)
Functions and Scoping
# Local variables are actually local
function process_file() {
local filename=$1
local -i count=0 # integer type
local -a lines=() # array type
while IFS= read -r line; do
lines+=($line)
((count++))
done < "$filename"
echo "Processed $count lines"
}
# Return values via echo or parameters
function get_extension() {
local filename=$1
echo "${filename##*.}"
}
ext=$(get_extension "document.pdf") # pdf
Arithmetic and Conditionals
# Arithmetic expansion
result=$((42 * 13))
((result++)) # No $ needed inside (( ))
# Floating point (zsh only)
result=$((3.14 * 2))
# Test operators
[[ -f $file ]] # file exists and is regular
[[ -d $dir ]] # directory exists
[[ -s $file ]] # file exists and is non-empty
[[ -r $file ]] # file is readable
[[ -w $file ]] # file is writable
[[ -x $file ]] # file is executable
[[ -L $file ]] # is symlink
[[ $str == pattern* ]] # pattern matching
[[ $str =~ regex ]] # regex matching
[[ $a -lt $b ]] # numeric less than
[[ $a -gt $b ]] # numeric greater than
[[ $a == $b ]] # string equality
[[ $a != $b ]] # string inequality
Traps and Cleanup
#!/usr/bin/env zsh
set -euo pipefail
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT INT TERM
# Your script logic here
echo "Working in $tmpdir"
# ... do work ...
# Cleanup happens automatically on exit or interrupt
Reading Input Properly
# Read file line by line (handles whitespace correctly)
while IFS= read -r line; do
echo "$line"
done < input.txt
# Read with timeout
if read -t 5 -p "Continue? [Y/n] " response; then
[[ $response =~ ^[Nn]$ ]] && exit 0
fi
# Read into array
lines=(${(f)"$(<file.txt)"})
Modifiers: Quick Transformations
file="/home/user/documents/archive.tar.gz"
# Modifiers apply to parameter expansion
${file:a} # absolute path
${file:t} # basename (archive.tar.gz)
${file:r} # remove extension (archive.tar)
${file:e} # extension (gz)
${file:h} # head (directory path)
${file:l} # lowercase
${file:u} # uppercase
${file:c} # capitalized first letter
# Chain modifiers
${file:t:r} # archive (basename without ext)
Autoloading and Module System
# Create reusable functions in your fpath
# ~/.zsh/functions/my-helpers
# In .zshrc
fpath+=(~/.zsh/functions)
autoload -Uz my-helpers
# Functions in that directory can be lazy-loaded
A Complete Example
Here's a real-world script I use to batch-process logs:
#!/usr/bin/env zsh
set -euo pipefail
LOG_DIR=${1:-/var/log/myapp}
DAYS_TO_KEEP=${2:-30}
ARCHIVE_DIR=${LOG_DIR}/archives
# Ensure archive directory exists
mkdir -p "$ARCHIVE_DIR"
# Find and compress logs older than 7 days, but not already compressed
for log in $LOG_DIR/*.log(N.m+7); do
[[ $log == *.gz ]] && continue
archive_name="${ARCHIVE_DIR}/${log:t:r}-$(date -r $log +%Y%m%d).gz"
echo "Compressing: ${log:t} -> ${archive_name:t}"
gzip -c "$log" > "$archive_name"
: > "$log" # truncate original
done
# Clean old archives
for archive in $ARCHIVE_DIR/*.gz(N.m+$((DAYS_TO_KEEP - 7))); do
echo "Removing old archive: ${archive:t}"
rm "$archive"
done
echo "Log rotation complete"
Key Takeaways
- Always use
set -euo pipefail— catches errors early - Use
localin functions — prevents variable pollution - Prefer
[[ ]]over[ ]— safer, more features - Glob qualifiers are your friend —
(.),(/),(om[1,5]) - Parameter expansion is faster than external commands —
${var##*/}vsbasename - Use
mktemp+trap— never leave temp files behind
Resources
man zshall— The complete referenceman zshexpn— Parameter expansion detailsman zshcompwid— Completion system- Zsh Wiki — Community patterns