Zsh Shell Style Guide
For shared conventions (naming, comments, formatting, [[ ]], $(()), error handling)
see the bash-style-guide skill. This skill covers everything zsh-specific.
For deep rule detail and all examples, read references/zsh-rules.md.
Routing: When to Use This Skill vs bash-style-guide
| Context | Skill to use |
|---|---|
| .zsh file, #!/bin/zsh shebang, dotfile (.zshrc etc.), zsh plugin | This skill (+ bash-style-guide for shared rules) |
| .sh file, #!/bin/bash, CI/cron/container script | bash-style-guide only |
| Reviewing an unknown script | Check shebang; if absent, check setopt/autoload usage |
When writing new dotfile code, default to zsh idioms — don't write bash-compatible code in your zsh dotfiles unless you explicitly need portability.
Quick-Reference Checklist
Shell Choice & Shebang
- [ ] Dotfiles / interactive config / plugins:
#!/bin/zsh - [ ] Scripts shared across machines or called from CI/cron: prefer
#!/bin/bash(portability) - [ ] Autoloaded function files: no shebang needed (they're sourced, not executed)
Isolation & Options
- [ ]
emulate -LR zshat the top of every autoloaded function and plugin file - [ ]
setopt LOCAL_OPTIONS LOCAL_TRAPSinside functions that change options - [ ] Scripts:
setopt ERR_EXIT PIPE_FAIL NO_UNSET WARN_CREATE_GLOBAL - [ ] Dotfiles (interactive only):
setopt EXTENDED_GLOB NULL_GLOBas needed
Variables & Quoting
- [ ] No word-splitting by default —
$vardoes NOT split on spaces (unlike bash); use${=var}only when splitting is intentional - [ ] Use
"${var}"for safety and cross-shell clarity even though unquoted is safer than bash - [ ] Separate
localdeclaration from command-substitution assignment (local swallows $?) - [ ]
typeset -gto explicitly create a global from inside a function (not a bare assignment)
Conciseness & Readability
- [ ] Re-evaluate for Conciseness: Always re-check the code to ensure it is as concise as possible using Zsh idioms (e.g., nested parameter expansions, zargs). It must preserve all functionality and remain human-readable.
Native Zsh vs External Pipelines
- [ ] Avoid external processes (
awk,sed,grep,tr,head,tail) when pure Zsh features can do the job faster and without forks. - [ ] Array filtering: use
${(M)array:#pattern}instead of piping togrep. - [ ] Case-insensitive matching: use
(#i)pattern(requiresEXTENDED_GLOB) instead ofawk '{tolower}'orgrep -i. - [ ] String replacement: use
${var//pattern/repl}instead ofsed 's/.../.../g'. - [ ] Trimming characters: use
${var//$'\n'/}instead oftr -d '\n'. - [ ] Parallel execution: Before using
xargs -Por GNUparallelfor slow/heavy processes, check if Zsh's nativezargs -P <concurrency>will do what's required. Becausezargsevaluates in the current shell, it securely inherits local variables and functions without needingexport.
Parameter Expansion Flags
- [ ]
${(U)var}/${(L)var}/${(C)var}for case transforms — nevertr - [ ]
${(j:sep:)array}to join array → string;${(s:sep:)str}to split string → array - [ ]
${(f)"$(cmd)"}to split command output on newlines → array - [ ]
${(q)var}for shell-quoting (logging, safe display) - [ ]
${(k)assoc}/${(v)assoc}/${(kv)assoc}for assoc array iteration - [ ]
${(u)array}deduplicate;${(o)array}sort;${(R)array}reverse
String Modifiers
- [ ]
${var:t}basename,${var:h}dirname,${var:r}strip extension,${var:e}extension - [ ]
${var:a}absolute path (resolves symlinks) - [ ]
${var:u}uppercase,${var:l}lowercase - [ ] Chain modifiers:
${var:h:t}= dirname then basename
⚠ Pattern Substitution Anchors — Critical Gotcha
- [ ]
%at start of pattern = end-of-string anchor, NOT a literal% - [ ]
#at start of pattern = start-of-string anchor, NOT a literal# - [ ] To match a literal
%or#: store it in a variable first, then use the variablepct='%'; print "${str//${pct}/percent}" # correct print "${str//%/percent}" # WRONG — % is an anchor - [ ] See
references/zsh-rules.md §7for all variants and workarounds
Arrays (1-indexed!)
- [ ] First element is
$array[1], NOT$array[0] - [ ] Last element:
$array[-1]; slice:$array[2,5] - [ ] Length:
${#array}(same as bash) - [ ] Intersection
${a:*b}, difference${a:|b}, zip${a:^b} - [ ] Always expand with
"${array[@]}"(quoted) for iteration or command arguments - [ ] Copying Arrays (Assignment): Use
arr=( "$@" )orarr=( "$other_arr[@]" ). Avoidarr=( "${@}" )as it can result in an array with one empty string("")if the input is empty in certain contexts.
Associative Arrays
- [ ] Declare with
typeset -A mymap - [ ] Check key exists:
(( ${+mymap[key]} )) - [ ] Iterate:
for key val in "${(kv)mymap[@]}"; do …
Globbing
- [ ] Prefer glob qualifiers over
findfor simple cases - [ ]
*(.)files,*(/)dirs,*(x)executable,*(m-7)modified last 7 days - [ ]
*(N)nullglob (no error if no match) — use liberally - [ ]
**/*(N.)recursive files, nullglob - [ ]
setopt EXTENDED_GLOBfor^pattern,(a|b),#,##
print vs echo
- [ ] Prefer
print -r -- "…"overecho(raw, no escape processing, options-safe) - [ ]
print -P "…"for prompt-colour expansion (interactive only) - [ ]
print -l "${array[@]}"for one-item-per-line output
Option Parsing
- [ ] Use
zparseopts— notgetopts(see skeleton below)
Functions & Autoload
- [ ]
autoload -Uz funcnamefor lazy-loaded functions - [ ] Add your functions directory to
fpathbeforecompinit - [ ] Name private helpers with
_prefix:_my_plugin_helper() - [ ] All variables
local— usetypeset -gonly when global is genuinely needed
Hooks
- [ ] Use
add-zsh-hookto register hooks — never redefine hook functions directly - [ ] Available:
precmd,preexec,chpwd,periodic,zshaddhistory,zshexit
Script Directory
- [ ] No
BASH_SOURCEin zsh — use the$0idiom:0="${${ZERO:-${0:#$ZSH_ARGZERO}}:-${(%):-%N}}" typeset -r SCRIPT_DIR="${0:A:h}"
Dotfile Layout
- [ ]
.zshenv— env vars only, minimal, fast (runs for every shell including scripts) - [ ]
.zprofile— login-time setup (eval "$(brew shellenv)", etc.) - [ ]
.zshrc— interactive config only (aliases, prompt, plugins, completions, keybindings) - [ ] Respect
$ZDOTDIRfor XDG layout; set it in~/.zshenv - [ ] Split
.zshrcintoconf.d/*.zshsourced with a glob loop
Skeletons
Executable Zsh Script
#!/bin/zsh
#
# Brief description of what this script does.
emulate -LR zsh
setopt ERR_EXIT PIPE_FAIL NO_UNSET WARN_CREATE_GLOBAL
zmodload zsh/datetime
0="${${ZERO:-${0:#$ZSH_ARGZERO}}:-${(%):-%N}}"
typeset -r SCRIPT_DIR="${0:A:h}"
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
typeset -r CONFIG_FILE="${SCRIPT_DIR}/config.yaml"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
err() {
print -r -- "[$(strftime '%Y-%m-%dT%H:%M:%S%z' ${EPOCHSECONDS})]: $*" >&2
}
#######################################
# Example function.
# Arguments:
# $1 - Input string.
# Outputs:
# Writes lowercased string to stdout.
#######################################
process_input() {
local input="$1"
print -r -- "${input:l}"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
#######################################
# Entry point.
# Arguments:
# All script arguments passed through.
#######################################
main() {
if (( $# < 1 )); then
err "Usage: ${0:t} <input>"
return 1
fi
local result
result="$(process_input "$1")"
print -r -- "${result}"
}
main "$@"
Autoloaded Function File (functions/my_func)
# No shebang — sourced, not executed
emulate -LR zsh
setopt LOCAL_OPTIONS LOCAL_TRAPS
#######################################
# Brief description.
# Arguments:
# $1 - Input string.
# Outputs:
# Writes result to stdout.
# Returns:
# 0 on success, 1 on error.
#######################################
local -a help_flag
zparseopts -D -E h=help_flag -help=help_flag || return 1
if (( ${#help_flag} )); then
print "Usage: my_func [-h] <input>"
return 0
fi
local input="${1:?'input required'}"
print -r -- "${input:u}"
fpath + autoload Setup (in .zshrc)
# Add before compinit
fpath=( "${ZDOTDIR}/functions" "${ZDOTDIR}/completions" "$fpath[@]" )
# Autoload everything in the functions directory
for _f in "${ZDOTDIR}/functions"/*(N.x:t); do
autoload -Uz "${_f}"
done
unset _f
autoload -Uz compinit
compinit -d "${XDG_CACHE_HOME:-${HOME}/.cache}/zsh/zcompdump-${ZSH_VERSION}"
conf.d Loader (in .zshrc)
for _conf in "${ZDOTDIR}/conf.d"/*.zsh(N.); do
source "${_conf}"
done
unset _conf
For full rule-by-rule detail with all examples, read references/zsh-rules.md.