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 fails
  • set -u: Treat unset variables as errors
  • set -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

QualifierMeaning
.Regular files
/Directories
@Symbolic links
*Executable files
rReadable by owner
wWritable by owner
xExecutable by owner
RReadable by world
WWritable by world
XExecutable by world
L+sizeLarger than size (in bytes, or use k/M/G)
Lm+1Larger than 1MB
omSort by modification time (oldest first)
OmSort by modification time (newest first)
[1,5]Only first 5 matches
DInclude 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

  1. Always use set -euo pipefail — catches errors early
  2. Use local in functions — prevents variable pollution
  3. Prefer [[ ]] over [ ] — safer, more features
  4. Glob qualifiers are your friend(.), (/), (om[1,5])
  5. Parameter expansion is faster than external commands${var##*/} vs basename
  6. Use mktemp + trap — never leave temp files behind

Resources

  • man zshall — The complete reference
  • man zshexpn — Parameter expansion details
  • man zshcompwid — Completion system
  • Zsh Wiki — Community patterns

Need help automating your workflow?

I build custom CLI tools and automation scripts that save hours of manual work.