Back to Blog

Zsh Lesson20 Error Handling

Sandy LaneSandy Lane

Video: Zsh Lesson20 Error Handling by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 20: Error Handling — Exit Codes, set -euo, trap

Exit code 0 = success, non-zero = failure. $? holds the last exit code. && chains on success, || on failure. Strict mode: set -euo pipefail. trap CMD EXIT for guaranteed cleanup. Use these in every script over 10 lines.

A script that doesn't handle errors silently does the wrong thing. Strict mode catches bugs early.

Exit codes

Every command returns an integer (0-255). 0 means success; non-zero means error.

ls /tmp > /dev/null
echo $?
# 0

ls /nonexistent 2>/dev/null
echo $?
# 1

$? holds the most recent exit code. Standard codes:

  • 0 — success.
  • 1 — generic error.
  • 2 — misuse of shell builtins / bad arguments.
  • 126 — not executable.
  • 127 — command not found.
  • 128+N — killed by signal N (e.g., 130 = SIGINT/Ctrl-C).
  • 255 — exit value out of range.

Custom exits

exit 0       # success
exit 1       # error
exit 42      # custom

In a function, return N does the same. For consistent reporting:

die() {
  echo "Error: $*" >&2
  exit 1
}

[[ -f config.yaml ]] || die "config.yaml missing"

Conditional execution: && and ||

true && echo "succeeded"
false || echo "failed"

mkdir -p backup && cp -a src backup/    # copy only if mkdir worked
some_check || exit 1                     # exit if check fails

These chain commands by exit status. Read left-to-right.

&& and || are non-short-circuiting only in the sense that you can chain:

cmd1 && cmd2 || cmd3
# cmd1 && cmd2 — if both succeed, run that group
# || cmd3 — if EITHER cmd1 or cmd2 failed, run cmd3

This is a common bug source. For clarity, use if/else:

if cmd1 && cmd2; then
  echo "both ok"
else
  cmd3
fi

set -e: exit on error

#!/usr/bin/env zsh
set -e

ls /nonexistent     # this fails
echo "never runs"

set -e (or set -o errexit) makes the script exit on the first failing command. Catches:

  • File-not-found errors.
  • Failed cp/mv/rm.
  • Network failures.
  • Anything returning non-zero.

set -u: error on unset variables

#!/usr/bin/env zsh
set -u

echo "$undefined_var"
# zsh: undefined_var: parameter not set
# (script exits)

Catches typos: ${VAR} vs ${VARS} would be silently empty without -u.

To safely access "maybe unset":

echo "${VAR:-default}"

set -o pipefail: pipeline errors propagate

#!/usr/bin/env zsh
# Without pipefail
false | true
echo $?    # 0 — only the LAST command's status

# With pipefail
set -o pipefail
false | true
echo $?    # 1 — failure propagates

Without pipefail, cmd1 | cmd2 | cmd3 returns only cmd3's status. If cmd1 fails (e.g., file not found), you'd never know.

Strict mode: set -euo pipefail

#!/usr/bin/env zsh
set -euo pipefail

# Now the script:
# - exits on first error (-e)
# - errors on unset variables (-u)
# - propagates pipe failures (-o pipefail)

The unofficial standard. Add to every script over 10 lines.

A useful addition for debugging:

set -x    # print each command before running

For really paranoid scripts, also:

set -E    # ERR trap inherited by functions and subshells

Trap: cleanup on any exit

#!/usr/bin/env zsh
set -euo pipefail

WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT

echo "Working in $WORK_DIR"
# ... do stuff ...

trap CMD EXIT runs CMD whenever the script exits — success, error, signal, anything. The work directory is always cleaned up.

For more specific signal handling:

trap 'echo "Cleaning up"' EXIT
trap 'echo "Interrupted"; exit 130' INT
trap 'echo "Got TERM"; exit 143' TERM
trap 'echo "Error on line $LINENO"' ERR

ERR is a special trap for "command failed under set -e" — useful for error reporting.

Catching specific errors

set -e exits on any failure. To allow a specific command to fail:

# Append || true to bypass
some_failable_command || true

# Or wrap in if
if ! some_check; then
  echo "check failed but we continue"
fi

|| true is the idiomatic "I know this might fail; don't exit."

Error functions

log_error() {
  echo "[ERROR] $(date +%H:%M:%S) $*" >&2
}

log_info() {
  echo "[INFO] $(date +%H:%M:%S) $*" >&2
}

die() {
  log_error "$@"
  exit 1
}

[[ -f "$CONFIG" ]] || die "config not found: $CONFIG"
require_command() {
  command -v "$1" >/dev/null || die "missing required command: $1"
}

require_command git
require_command curl
log_info "All dependencies present"

A small library for a polished script.

Validating arguments

#!/usr/bin/env zsh
set -euo pipefail

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <input-file>" >&2
  exit 1
fi

INPUT="$1"

[[ -f "$INPUT" ]] || { echo "Not a file: $INPUT" >&2; exit 1; }
[[ -r "$INPUT" ]] || { echo "Not readable: $INPUT" >&2; exit 1; }

Validate before doing work. Fail fast with clear messages.

Retry with backoff

retry() {
  local max=$1; shift
  local n=0
  until "$@"; do
    n=$((n + 1))
    if (( n >= max )); then
      echo "Failed after $max attempts" >&2
      return 1
    fi
    echo "Attempt $n failed; retrying in $((n * 2))s..." >&2
    sleep $((n * 2))
  done
}

retry 5 curl -fsSL "https://api.example.com/health"

Exponential backoff for flaky operations. Returns 0 on success.

A robust script template

#!/usr/bin/env zsh
set -euo pipefail

SCRIPT_NAME=${0##*/}

usage() {
  cat <<EOF
Usage: $SCRIPT_NAME [-v] <input>

Options:
  -v   verbose
  -h   show this help
EOF
}

log() { echo "[$SCRIPT_NAME] $*" >&2; }
die() { log "ERROR: $*"; exit 1; }

# Cleanup on any exit
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT

# Parse args
VERBOSE=0
while getopts "vh" opt; do
  case $opt in
    v) VERBOSE=1 ;;
    h) usage; exit 0 ;;
    *) usage; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

[[ $# -ge 1 ]] || { usage; exit 2; }
INPUT="$1"

[[ -f "$INPUT" ]] || die "not a file: $INPUT"

# Main work
log "Processing $INPUT in $WORK_DIR"
# ...

log "Done"

This is the skeleton for any non-trivial script. Strict mode + cleanup trap + arg parsing + log helpers + main work.

Common stumbles

set -e and conditional checks. set -e doesn't trigger on commands in if conditions, &&, ||, or piped (without pipefail). It triggers on bare failing commands.

((count++)) exits with set -e. When count was 0, the expression evaluates to 0 (false). Use count=$((count + 1)) or ((count++)) || true.

grep returning 1 on no match. With set -e, kills your script. Wrap: grep ... || true.

Errors going to stdout. Use >&2 for errors so users can pipe stdout cleanly.

Trap before cd. If you cd /tmp/foo then set the trap to rm -rf ., you might delete the wrong thing. Set traps with absolute paths captured before the cd.

Trap and exec. exec command ... replaces the script's process. The trap doesn't fire because the original process is gone.

return outside function. return from the top level of a sourced script returns to the caller. From a regular script, it errors.

Forgot set -e and quietly ignored errors. Most scripts in the wild don't use it. Yours should.

What's next

Lesson 21: find and fd. Search for files by name, type, size, age.

Recap

Exit code 0 = success. $? holds last exit; exit N returns custom code. && chains on success, || on failure. Strict mode: set -euo pipefail (errexit, nounset, pipefail). trap CMD EXIT for guaranteed cleanup; trap CMD ERR for error handlers. Validate inputs early; fail fast with clear messages to stderr. Use || true to bypass set -e for known-failable commands.

Next lesson: find and fd.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.