Patterns#

A handful of patterns make Zsh scripts safer and more maintainable. The core ideas are the same as bash, but the option names are zsh-spelled and a few of bash’s foot-guns are already absent (no word-splitting on unquoted expansion, last pipeline stage in the current shell).

Strict Mode#

Fail fast on errors, undefined variables, and broken pipes.

$ #!/usr/bin/env zsh
$ setopt err_exit no_unset pipefail
$ setopt warn_create_global
$ IFS=$'\n\t'
  • err_exit (-e), exit on the first command that returns non-zero.

  • no_unset (-u), error on unset variable references.

  • pipefail, a pipeline fails if any stage fails.

  • warn_create_global, warn when a function assigns to a name not declared local (catches the silent-clobber bug at development time).

Cleanup#

Use trap to release resources whether the script succeeds or fails.

$ tmp=$(mktemp)
$ trap 'rm -f "$tmp"' EXIT

Zsh also accepts named trap functions (TRAPEXIT, TRAPINT, TRAPTERM, TRAPHUP); cleaner than inline strings for non-trivial handlers.

$ tmp=$(mktemp)
$ TRAPEXIT() { rm -f "$tmp" }
$ TRAPINT()  { print -u2 -- "interrupted"; return 130 }

Argument Parsing#

For short flags, use zparseopts from zsh/zutil. It handles short and long flags, mandatory and optional arguments, and stops cleanly on --.

$ zmodload zsh/zutil

$ local -A opts
$ zparseopts -D -E -A opts -- \
$   h=flag_h -help=flag_help \
$   v=flag_v -verbose=flag_verbose \
$   f:=flag_f -file:=flag_file

$ (( ${+flag_h[1]} + ${+flag_help[1]} )) && { print "usage: ..."; exit 0 }
$ (( ${+flag_v[1]} + ${+flag_verbose[1]} )) && verbose=1
$ file=${flag_f[2]:-${flag_file[2]:-default.txt}}

POSIX getopts also works (short flags only).

For a fully manual loop, the same structure works as bash.

$ while (( $# > 0 )); do
  $ case "$1" in
    $ --name)  name="$2"; shift 2 ;;
    $ --force) force=1; shift ;;
    $ --)      shift; break ;;
    $ *)       print -u2 -- "unknown: $1"; exit 2 ;;
  $ esac
$ done

Quoting#

Unquoted expansions in zsh do not word-split by default, so the bash advice “always double-quote everything” is less load- bearing. Quoting still expresses intent and guards against setopt SH_WORD_SPLIT being flipped on by a sourced file.

$ for f in "$@"; do
  $ mv -- "$f" "/tmp/$f"
$ done

The right-hand side of == and != inside [[ ]] is still treated as a glob pattern; quote literals.

$ name="alice"
$ [[ $name == al* ]]      # true: glob match
$ [[ $name == "al*" ]]    # false: literal compare

Subshells & Pipes#

Unlike bash, the last stage of a pipeline runs in the current shell in zsh by default. while read loops on the right of a pipe can mutate outer state directly.

$ count=0
$ printf 'a\nb\nc\n' | while read -r _; do
  $ (( count++ ))
$ done
$ print -- "$count"        # 3 in zsh, 0 in bash

Earlier stages still run in subshells, so mutate only on the right end of the pipe. Process substitution (< <(cmd)) remains the cleanest fix when the loop has to be on the left.

Module Discipline#

Load zsh modules and autoloaded functions at the top of the script, near setopt, so the rest of the file can assume they exist.

$ #!/usr/bin/env zsh
$ setopt err_exit no_unset pipefail
$ zmodload zsh/datetime zsh/zutil
$ autoload -Uz add-zsh-hook zmv

Profiling#

Slow startup is the single most common complaint about zsh. zprof measures it.

$ # at the top of ~/.zshrc
$ zmodload zsh/zprof

$ # at the bottom
$ zprof | head -20

Read the table top-down. The columns are total time, number of calls, and self time; whatever is at the top is the largest share of the startup budget.

References#

  • Overview for the language-level error-handling surface (set -e, setopt, traps).

  • I/O and Pipelines for pipefail and the pipeline semantics that pair with these patterns.

  • Tools for zprof, shellcheck (limited zsh support), and the rest of the toolchain.

  • man 1 zshoptions (every option name and what it does).

  • man 1 zshbuiltins (trap, setopt, zparseopts).