← Back to DevBytes

Zsh Scripting: Error Handling Complete Guide

Zsh Scripting: Error Handling Complete Guide

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles