Error handling is one of the most overlooked aspects of shell scripting. Many developers write Zsh scripts that assume everything will go smoothly, only to discover at 2 AM that a missing file, a failed network call, or a typo has cascaded into a broken deployment. This guide walks you through everything you need to know to write robust, predictable Zsh scripts that fail gracefully and tell you exactly what went wrong.
What Is Error Handling in Zsh?
Error handling in Zsh refers to the set of techniques and built-in mechanisms that allow your script to detect, report, and recover from failures. Unlike languages such as Python or Rust, Zsh does not throw exceptions. Instead, every command returns an exit status — an integer between 0 and 255 — where 0 means success and any non-zero value indicates failure. Error handling is the art of checking those exit statuses and responding appropriately.
Zsh also offers several options that change how the shell itself reacts to errors, trap handlers that run custom code when signals or errors occur, and pattern-matching tools that help validate input before it causes problems downstream.
Why Error Handling Matters
Prevents silent failures: A command that fails but is ignored can corrupt data or skip critical steps.
Improves debuggability: Clear error messages with context save hours of investigation.
Protects against partial execution: If step 3 of 10 fails, you do not want steps 4 through 10 to run blindly.
Enables safe automation: Scripts run by cron or CI pipelines cannot prompt a human for help.
Makes scripts self-documenting: Explicit checks communicate assumptions to future readers.
Understanding Exit Statuses
Every command in Zsh sets the special variable $? to its exit status immediately after it finishes. You can read this variable to decide what to do next.
#!/usr/bin/env zsh
mkdir /tmp/my_project
if (( $? != 0 )); then
print "Failed to create directory" >&2
exit 1
fi
print "Directory created successfully"
The >&2 redirect sends the message to standard error, which is the correct stream for diagnostics. The exit 1 stops the script immediately so no downstream commands run.
A cleaner pattern is to check the command directly in the if statement, because $? is easily overwritten by the next command:
#!/usr/bin/env zsh
if mkdir /tmp/my_project; then
print "Directory created successfully"
else
print "Failed to create directory" >&2
exit 1
fi
The set -e Option: Fail Fast
Manually checking every command is tedious. Zsh inherits the errexit option from POSIX shells, which makes the script exit immediately when any command fails.
#!/usr/bin/env zsh
set -e
mkdir /tmp/my_project
cd /tmp/my_project
git init
print "Project initialized"
If mkdir fails, the script stops right there. No cd, no git init, no misleading success message.
However, set -e has subtle behavior. It does not trigger when a command is part of an if condition, when it is followed by || or &&, or when its return value is explicitly ignored. This is actually useful, because it lets you handle expected failures:
#!/usr/bin/env zsh
set -e
# This will not exit the script if the directory already exists
mkdir -p /tmp/my_project || true
# This will exit if the file does not exist
cat /tmp/my_project/config.toml
Essential Options for Robust Scripts
Most production Zsh scripts start with a standard set of options. Together they catch a wide range of common mistakes.
#!/usr/bin/env zsh
set -e # Exit on error
set -u # Treat unset variables as errors
set -o pipefail # A pipeline fails if any command in it fails
set -o errtrace # ERR trap is inherited by functions
The pipefail option deserves special attention. By default, the exit status of a pipeline is the status of the last command. That means false | true succeeds. With pipefail, the pipeline fails if any segment fails, which is almost always what you want.
#!/usr/bin/env zsh
set -eo pipefail
# Without pipefail, this would "succeed" because grep is the last command
cat /nonexistent/file | grep "pattern"
Using trap for Cleanup and Error Handling
The trap builtin lets you run code when the script receives a signal or when an error occurs. This is essential for cleaning up temporary files, closing connections, or logging failures.
Cleanup on Exit
#!/usr/bin/env zsh
set -euo pipefail
temp_file=$(mktemp)
cleanup() {
print "Removing temporary file..."
rm -f "$temp_file"
}
trap cleanup EXIT
print "Working with $temp_file"
# ... do work ...
# The file is removed automatically when the script exits,
# whether it succeeds, fails, or is interrupted.
Handling Errors with ERR
The ERR trap fires whenever a command fails and set -e is active. Combined with errtrace, it propagates into functions.
#!/usr/bin/env zsh
set -euo pipefail
set -o errtrace
error_handler() {
local exit_code=$?
local line=$1
print "Error on line $line: command exited with status $exit_code" >&2
exit "$exit_code"
}
trap 'error_handler $LINENO' ERR
false # This triggers the handler
The $LINENO variable tells you exactly where the failure happened, which is invaluable for debugging long scripts.
Combining Cleanup and Error Handling
#!/usr/bin/env zsh
set -euo pipefail
set -o errtrace
temp_dir=$(mktemp -d)
on_error() {
local exit_code=$?
print "Script failed at line $1 with exit code $exit_code" >&2
exit "$exit_code"
}
on_exit() {
print "Cleaning up $temp_dir"
rm -rf "$temp_dir"
}
trap 'on_error $LINENO' ERR
trap on_exit EXIT
print "Creating files in $temp_dir"
touch "$temp_dir/a.txt"
touch "$temp_dir/b.txt"
# Simulate a failure
false
print "This line never runs"
Handling Errors with || and &&
For simple cases, inline operators are more readable than full if blocks.
#!/usr/bin/env zsh
# Run fallback if the first command fails
mkdir /tmp/my_project || { print "Cannot create directory" >&2; exit 1; }
# Chain dependent commands
cd /tmp/my_project && git init && print "Ready"
You can also assign a default value when a command fails:
#!/usr/bin/env zsh
# Use the system Python, or fall back to a known path
python_bin=$(command -v python3) || python_bin="/usr/local/bin/python3"
print "Using Python at: $python_bin"
Validating Input
Many errors come from bad input, not from failing commands. Validate early and fail fast.
#!/usr/bin/env zsh
set -euo pipefail
validate_args() {
if (( $# != 2 )); then
print "Usage: $0 " >&2
exit 1
fi
local source=$1
local destination=$2
if [[ ! -f "$source" ]]; then
print "Error: source file '$source' does not exist" >&2
exit 2
fi
if [[ -e "$destination" ]]; then
print "Error: destination '$destination' already exists" >&2
exit 3
fi
}
validate_args "$@"
cp "$1" "$2"
print "Copied $1 to $2"
Working with Functions and Error Propagation
Functions in Zsh return the exit status of their last command. You can also use return with an explicit code. Without errtrace, an error inside a function does not trigger the ERR trap unless the function itself is the failing command.
#!/usr/bin/env zsh
set -euo pipefail
set -o errtrace
deploy_app() {
local env=$1
if [[ "$env" != "staging" && "$env" != "production" ]]; then
print "Invalid environment: $env" >&2
return 1
fi
print "Building for $env..."
make build
print "Deploying to $env..."
make deploy
}
deploy_app "$@"
print "Deployment complete"
If make build fails, the function returns a non-zero status, set -e causes the script to exit, and the ERR trap (if set) fires with the correct line number thanks to errtrace.
Logging Errors with Context
A good error message tells you what happened, where, and why. Build a small logging helper to keep messages consistent.
#!/usr/bin/env zsh
set -euo pipefail
set -o errtrace
log() {
local level=$1
shift
print "[$level] $*" >&2
}
die() {
log "ERROR" "$*"
exit 1
}
on_error() {
local exit_code=$?
log "FATAL" "Script failed at line $LINENO (exit code: $exit_code)"
exit "$exit_code"
}
trap on_error ERR
config_file="${1:-}"
[[ -n "$config_file" ]] || die "No config file provided"
[[ -f "$config_file" ]] || die "Config file '$config_file' not found"
log "INFO" "Loading configuration from $config_file"
source "$config_file"
log "INFO" "Configuration loaded successfully"
Handling Pipeline Errors in Detail
Even with pipefail, you sometimes need to know which command in a pipeline failed. Use process substitution or capture each stage separately.
#!/usr/bin/env zsh
set -euo pipefail
# Capture output and check each stage
data=$(curl -fsSL https://example.com/data.json) || die "Failed to download data"
parsed=$(print "$data" | jq '.items') || die "Failed to parse JSON"
count=$(print "$parsed" | jq 'length') || die "Failed to count items"
print "Found $count items"
This approach gives you a specific error message for each failure point, which is far more useful than a single "pipeline failed" message.
Safe Variable Expansion
With set -u, referencing an unset variable is an error. Use default values and the ${var:?message} form to fail with a clear message.
#!/usr/bin/env zsh
set -euo pipefail
# Fail with a custom message if DATABASE_URL is unset or empty
: "${DATABASE_URL:?DATABASE_URL must be set}"
# Provide a default value
port="${PORT:-5432}"
print "Connecting to $DATABASE_URL on port $port"
The : command is a no-op that evaluates its arguments, making it a clean way to validate variables without running any other command.
Best Practices
Always start scripts with set -euo pipefail. Add set -o errtrace if you use functions and an ERR trap.
Use #!/usr/bin/env zsh instead of a hardcoded path so the script works across systems.
Quote all variable expansions to prevent word splitting and glob expansion from corrupting paths with spaces.
Write errors to stderr using >&2 so they do not pollute stdout when the script is piped.
Use meaningful exit codes. Reserve 0 for success, 1 for general errors, and 2+ for specific failure types.
Validate input at the top of the script before doing any real work.
Set up traps early so cleanup runs even if the script fails during setup.
Avoid eval unless absolutely necessary; it is a common source of injection bugs.
Test failure paths explicitly. Temporarily break a command and confirm the script exits cleanly with the right message.
Keep functions small and focused. Each function should do one thing and return a clear status.
A Complete Example
Here is a script that ties together everything covered so far. It downloads a file, validates it, processes it, and cleans up — all with proper error handling.
#!/usr/bin/env zsh
set -euo pipefail
set -o errtrace
# --- Configuration ---
readonly SCRIPT_NAME=$0
readonly WORK_DIR=$(mktemp -d)
# --- Logging ---
log() {
local level=$1
shift
print "[$level] $*" >&2
}
die() {
log "ERROR" "$*"
exit 1
}
# --- Error and cleanup handlers ---
on_error() {
local exit_code=$?
log "FATAL" "Script failed at line $LINENO (exit code: $exit_code)"
exit "$exit_code"
}
on_exit() {
log "INFO" "Cleaning up $WORK_DIR"
rm -rf "$WORK_DIR"
}
trap on_error ERR
trap on_exit EXIT
# --- Input validation ---
if (( $# != 1 )); then
die "Usage: $SCRIPT_NAME "
fi
url=$1
[[ "$url" == http* ]] || die "URL must start with http"
# --- Main logic ---
log "INFO" "Downloading $url"
archive="$WORK_DIR/data.tar.gz"
curl -fsSL "$url" -o "$archive" || die "Download failed"
log "INFO" "Extracting archive"
tar -xzf "$archive" -C "$WORK_DIR" || die "Extraction failed"
log "INFO" "Processing files"
for file in "$WORK_DIR"/*.txt; do
[[ -f "$file" ]] || continue
line_count=$(wc -l < "$file")
log "INFO" "$file: $line_count lines"
done
log "INFO" "Done"
Conclusion
Error handling is what separates a quick hack from a dependable script. By combining set -euo pipefail with thoughtful traps, input validation, and clear logging, you can write Zsh scripts that fail loudly, clean up after themselves, and give you the information you need to fix problems fast. The patterns in this guide are not just defensive — they make your scripts easier to read, easier to maintain, and safe to run in automation where no human is watching. Start every script with the standard options, set up your traps early, and treat every command as something that might fail. Your future self, debugging at 2 AM, will thank you.