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).