Overview#
Zsh is a command interpreter in the Bourne lineage. Programs are written as a sequence of commands separated by newlines or semicolons. The interpreter reads each command, expands variables and globs, runs it, captures the exit status, and moves on.
As a language, Zsh is closer to Bash plus a generous pile of ergonomic upgrades than to a different shell. Strings are still the main data type, control flow is still built on exit codes, and functions still return integers. What Zsh gives the operator on top of that is sane word-splitting defaults, first-class arrays (1-based and unsplit on expansion), parameter-expansion flags that replace whole pipelines, and a module system for the features the core shell doesn’t ship.
The page is grouped by stage so the reader can build a mental model in one pass. Foundation is the lexical surface (syntax). Operations is what the operator does to values (operators, strings). Flow is how a script makes decisions (control, functions). Wiring is how programs connect (I/O, subshells). Failure covers exit codes, strict mode, and traps. Re-use is sourcing modules and autoload. Runtime is what zsh is underneath. The last two sections are battle-drill tasks and references.
For the variable types (string, integer, float, indexed array, associative array, plus the readonly, exported, case-folded, nameref, tied, and local attributes) see Structures.
Syntax#
A script begins with a shebang and is made executable.
$ #!/usr/bin/env zsh
$ echo "hello"
Keywords#
Reserved words include.
if then elif else fi case esac for select while until do done
function in time { } [[ ]] ! return break continue foreach end
coproc nocorrect repeat
Identifiers#
A variable name is a letter or underscore followed by any mix of
letters, digits, and underscores. Names are case-sensitive. The
shell has no separate namespace for variables, functions, and
aliases; the most recent binding shadows any other. Sigils mark
the use site (var=value declares; $var reads).
$ MY_VAR=1 # valid
$ _hidden=secret # valid
$ 2nd=bad # invalid: cannot start with a digit
Literals#
Zsh has only string literals, but several syntaxes produce them.
Single quotes are byte-for-byte (no expansion). Double quotes
expand $var, $(cmd), and backslash. Unquoted words are
not word-split by default (the biggest semantic difference
from bash). $'...' is the C-style escape form (\n, \t,
\x41). Numeric literals are decimal by default; 0x is hex,
0 is octal, and base#digits is an arbitrary radix inside
(( )).
$ echo 'literal $HOME' # literal $HOME
$ echo "expanded $HOME" # /home/operator
$ echo $'tab\there' # tab + here
$ echo $(( 0xff )) # 255
$ echo $(( 2#1010 )) # 10
Expressions#
A simple command is a sequence of words separated by spaces;
the first word is the command, the rest are arguments. Commands
end at a newline, a semicolon, an & (background), or one of
the list operators && / || / |. A compound command
groups several inside { ... } (current shell) or ( ... )
(subshell).
$ echo a; echo b # two commands on one line
$ true && echo ok # second runs only if first succeeded
$ false || echo fallback # second runs only on failure
$ { echo a; echo b } > log # group in current shell
$ ( cd /tmp; pwd ) # group in subshell
Operators#
Zsh uses different operator dialects in different contexts.
Arithmetic in (( )) or $(( )) follows C-like rules. String,
file, and regex tests live in [[ ]] (preferred). The legacy
POSIX [ ... ] / test form still works but is quirkier
around quoting.
$ (( a = 2 + 3 )) # arithmetic, no $ needed inside
$ [[ "$x" == "y" ]] # string equality
$ [[ -f /etc/hosts ]] # file exists and is regular
$ [[ "$s" =~ ^[0-9]+$ ]] # regex match
Arithmetic#
Inside (( )) or $(( )), zsh evaluates a C-like expression.
Variables expand without a leading $. Unlike bash, zsh has
native floating-point when the operand is a float (setopt
FORCE_FLOAT makes it default) and exposes math functions via the
zsh/mathfunc module.
Operator |
Effect |
|---|---|
|
Add, subtract, multiply, divide, remainder |
|
Exponent ( |
|
Pre- / post-increment, decrement |
|
Compound assignment |
|
Bitwise and, or, xor, not, shifts |
|
Ternary |
$ (( x = (1 + 2) * 3 )) # 9
$ (( y = x > 5 ? 1 : 0 )) # ternary
$ zmodload zsh/mathfunc
$ (( z = sqrt(2.0) )) # native float
Comparison#
Inside [[ ]], string and numeric tests use different operators.
Reaching for the wrong family is the most common scripting bug.
String ( |
Numeric ( |
Meaning |
|---|---|---|
|
|
Equal |
|
|
Not equal |
|
|
Less than |
|
|
Less than or equal |
|
|
Greater than |
|
|
Greater than or equal |
|
String |
|
|
String |
|
|
String matches regex (captures in |
Tests#
Inside [[ ]], unary file-test operators ask the kernel about a
path. Same surface as bash; zsh adds -h as a synonym for
-L and a few extras (-N, modified since last read).
Test |
True when |
|---|---|
|
Path exists (any type) |
|
Regular file |
|
Directory |
|
Symlink (does not follow) |
|
Caller has read / write / execute permission |
|
File exists and is non-empty |
|
Owned by effective UID / GID |
|
SUID / SGID / sticky bit set |
|
|
|
|
Logical#
Outside [[ ]], zsh chains commands by exit code with &&
and ||. Inside [[ ]], the same operators combine tests.
! negates.
$ true && echo ok
$ false || echo fallback
$ [[ -f f && -r f ]] && cat f
$ [[ ! -f f ]] && echo missing
Assignment#
NAME=VALUE (no spaces around =) attaches a value to a
name. Compound forms are arithmetic-only and live in (( )).
typeset and its synonyms (declare, integer, float)
add attributes (see Structures).
Form |
Effect |
|---|---|
|
Bind |
|
Append to a string, or append to an array |
|
Arithmetic evaluation, no |
|
Compound arithmetic |
|
Integer-attributed; bare |
|
Float-attributed |
Strings#
Zsh strings interpolate $var and ${expr}. The braces let
the operator add an expansion operator that modifies the string
on the fly. The parameter-expansion flag syntax ${(F)var}
is zsh’s most distinctive scripting feature; flags chain inside
the parens and replace what bash needs awk or tr for.
$ name="alice"
$ echo "hello, $name" # interpolation
$ echo "hi, ${(C)name}" # capitalise (flag)
$ echo "${(j:,:)array}" # join array with ","
$ echo "${(s:,:)csv}" # split string on ","
Common parameter-expansion forms (shared with bash).
Form |
Effect |
|---|---|
|
Plain expansion |
|
Use |
|
Same, and assign |
|
Error out with |
|
Substring |
|
Length |
|
Strip shortest / longest prefix |
|
Strip shortest / longest suffix |
|
Replace first / every occurrence |
Zsh-only flag forms inside ${(...)var}.
Flag |
Effect |
|---|---|
|
Uppercase / lowercase the whole value |
|
Capitalise each word |
|
Join an array with |
|
Split on |
|
Split on newlines |
|
Join with newlines |
|
Sort ascending / descending |
|
Remove duplicates |
|
Numeric sort (with |
|
Treat the value as another variable’s name |
$ paths=(/usr/bin /bin /usr/local/bin)
$ echo "${(j.:.)paths}" # /usr/bin:/bin:/usr/local/bin
$ echo "${(@s.:.)PATH}" # split PATH into array
$ echo "${(uon)numbers}" # sorted, unique, numeric
Command substitution captures a command’s stdout as a string.
$ today=$(date +%F)
$ shell=$(basename "$SHELL")
Quoting controls expansion. Double quotes preserve whitespace
and allow $var and $(...) to expand; single quotes
suppress all expansion.
$ echo "$HOME" # /home/operator
$ echo '$HOME' # $HOME
Regex#
Inside [[ ]] the =~ operator runs a POSIX ERE match (or
PCRE with setopt RE_MATCH_PCRE). Capture groups land in the
$match array (1-based) and the whole match in $MATCH.
Quote the variable, not the regex.
$ s="user42@example.com"
$ if [[ "$s" =~ ^([a-z]+)([0-9]+)@(.+)$ ]]; then
$ printf '%s\n' "user: $match[1]" \
$ "id: $match[2]" \
$ "host: $match[3]"
$ fi
user: user
id: 42
host: example.com
Globbing#
Globs are filename patterns the shell expands before the
command runs. Zsh defaults are more aggressive than bash: **
is always available, glob qualifiers and extended_glob add
filtering and exclusion. See Zsh for the qualifier
table.
Pattern |
Matches |
|---|---|
|
Any string except a leading dot |
|
Exactly one character |
|
One character from the set |
|
One character not in the set |
|
Recursive subdirectory match |
|
Numeric range ( |
|
Alternation |
|
Exclusion ( |
|
Null glob (no match expands to nothing) |
$ setopt extended_glob
$ ls **/*.log # recursive
$ ls *.log~*backup* # logs that don't match backup
$ ls **/(README|LICENSE) # alternation
Braces#
Brace expansion produces a literal list of strings regardless of whether the resulting names exist. Useful for repeated names with small variations. Same surface as bash.
Form |
Expands to |
|---|---|
|
|
|
|
|
|
|
|
$ mkdir -p logs/{web,db,cache}/{2024,2025}
$ cp config.yaml{,.bak} # quick backup
$ for i in {1..3}; do echo "host$i"; done
Control#
Zsh builds branching and looping on exit codes, the same way
bash does. The condition is any command; the shell runs it and
reads its exit status. 0 is truthy, anything else falsy.
Form |
When the operator reaches for it |
|---|---|
|
Any command can drive an if; the exit code is the test. |
|
String, glob, regex, and file tests. The default. |
|
Numeric / arithmetic tests. No |
|
One-line guard for a single step. |
|
Walk a fixed or globbed list of words. |
|
Zsh shorthand, no |
|
Arithmetic counter, C-style. |
|
Loop while the test stays true. |
|
Loop while the test stays false. |
|
Run the body |
|
One value, many patterns. First match wins. |
|
Numbered interactive menu on stdin. |
If#
if runs a command and branches on its exit status. [[ ]]
(string / file tests), (( )) (arithmetic), and any command
(grep -q, test, an exit-coded helper) all work as the
condition.
$ if [[ -f config.toml ]]; then
$ echo "found"
$ fi
flowchart LR
C{cond?} -->|true| T[then block] --> X([fi])
C -->|false| X
Add else and elif:
$ if [[ -z "$1" ]]; then
$ echo "no argument"
$ elif [[ "$1" == "help" ]]; then
$ echo "usage: ..."
$ else
$ echo "got $1"
$ fi
Three test dialects sit on the same syntax.
$ if [[ "$user" == "root" ]]; then echo "root"; fi # string
$ if (( count > 10 )); then echo "many"; fi # arithmetic
$ if grep -q ERROR app.log; then echo "found"; fi # any command
Quoting is less critical than in bash because zsh does not
word-split unquoted expansions. The right-hand side of == is
still a glob pattern by default, so quote literals.
$ name="alice"
$ [[ $name == al* ]] # true: glob match
$ [[ $name == "al*" ]] # false: literal compare
For#
for runs the body once per word. Zsh accepts the bash form
and a shorter form without in / do / done.
$ for f in *.txt; do
$ echo "$f"
$ done
$ for f (*.txt) print -r -- $f # zsh shorthand
$ for h in web01 db01 cache01; do # literal list
$ ssh "$h" uptime
$ done
The C-style for (( init; cond; step )) walks an arithmetic
counter.
$ for ((i=0; i<10; i++)); do
$ echo "$i"
$ done
Iterate an array. Quoting is optional because zsh does not split,
but quoting still expresses intent and protects against setopt
SH_WORD_SPLIT being flipped on.
$ targets=(web01 "db prod" cache01)
$ for t in $targets; do echo "[$t]"; done
Less of a foot-gun than bash. for f in $(ls) and for f
in $(find ...) do not re-glob the result in zsh because
command substitution is not word-split. Still bad form; prefer a
glob or null-delimited read.
$ for f in **/*.log; do echo "$f"; done # safe glob
$ files=( **/*.log(.) ) # null if none
$ for f in $files; do echo "$f"; done
Repeat#
repeat N runs the body N times. The zsh-only loop that
makes “do this N times” a one-liner.
$ repeat 3 print -- "knock"
knock
knock
knock
While#
while runs the body for as long as the test stays true. The
standard reach for read-driven input loops, polling, and any
loop whose iteration count is not known in advance.
$ while IFS= read -r line; do
$ echo "$line"
$ done < input.txt
Pitfall (the bash gotcha that goes away). In bash, each stage
of a pipeline runs in a subshell, so variables changed inside a
while on the right of a pipe vanish when the pipe ends. In
zsh, the last stage of a pipeline runs in the current shell by
default. The fix bash needs (shopt -s lastpipe, process
substitution) is not needed in zsh.
$ count=0
$ printf 'a\nb\nc\n' | while read -r line; do
$ (( count++ ))
$ done
$ echo "$count" # 3 in zsh (0 in bash)
Until#
until runs the body while the condition is false and
stops once the condition becomes true. Same structure as while,
polarity flipped. The standard reach for retry / wait-for-ready
patterns.
$ until ping -c1 -W1 host >/dev/null 2>&1; do
$ sleep 1
$ done
Add a deadline so the loop cannot spin forever.
$ deadline=$(( EPOCHSECONDS + 30 )) # zsh/datetime exposes EPOCHSECONDS
$ until curl -fsS https://target/healthz >/dev/null; do
$ (( EPOCHSECONDS >= deadline )) && { echo "timeout" >&2; exit 1; }
$ sleep 1
$ done
Case#
case tests one value against a sequence of glob patterns,
not regular expressions. The first match wins; ;; closes the
branch. With setopt EXTENDED_GLOB the patterns get the same
~ / ^ operators as ordinary globs.
$ case "$1" in
$ start) echo "starting" ;;
$ stop|halt) echo "stopping" ;;
$ <0-9>*) echo "numeric: $1" ;; # zsh numeric range
$ *.log) echo "log file" ;;
$ "") echo "empty" ;;
$ *) echo "unknown" ;;
$ esac
Branch terminators are the same as bash: ;; ends, ;&
falls through, ;;& keeps evaluating later patterns.
Select#
select builds an interactive numbered menu from a list and
reads the operator’s choice on stdin. PS3 is the menu prompt.
$ PS3="pick a host: "
$ select host in web01 db01 cache01 quit; do
$ case "$host" in
$ quit) break ;;
$ "") echo "invalid: $REPLY" ;;
$ *) echo "chose $host"; break ;;
$ esac
$ done
Loop Control#
break exits the innermost loop; break N exits N
levels. continue skips to the next iteration; continue N
skips N levels. return ends a function with a given exit
status; exit ends the whole script.
$ for f in *.log; do
$ [[ -s "$f" ]] || continue # skip empty files
$ grep -q ERROR "$f" && break # stop on first hit
$ echo "$f"
$ done
Functions#
Zsh functions accept positional arguments through $1, $2,
and so on, and return string output via print / echo
captured by command substitution. return N ends a function
with exit status N. local (or typeset inside a
function) scopes variables.
$ add() {
$ local a="$1" b="$2"
$ print -- $((a + b))
$ }
$ result=$(add 2 3)
Anonymous functions run inline and pass their arguments after the body. Useful for one-off scopes.
$ () { local tmp; tmp=$(mktemp); print "$tmp" } "ignored arg"
autoload -Uz fn defers loading the body until the function
is first called; the body lives in a file named fn on
$fpath. See Libraries.
I/O#
Three streams (stdin 0, stdout 1, stderr 2) are the
universal interface. read pulls a line; print and
printf write to stdout; redirection operators rewire any of
the three. The full surface (every redirection operator,
MULTIOS, heredocs, pipelines, process substitution, named
pipes) lives in I/O and Pipelines.
$ read -r line < input.txt
$ print -- "hello" # to stdout
$ print -u2 -- "oops" # to stderr
$ cmd > out.txt 2> err.txt # split each stream
$ cmd <<< "one-line stdin"
Positional parameters and environment.
Form |
Meaning |
|---|---|
|
Positional arguments |
|
All arguments (array; one element per token) |
|
Argument count |
|
Script name (or function name inside a function; |
|
Own process ID |
|
Exit status of the last command |
|
PID of the last background command |
$ export PATH="$HOME/bin:$PATH"
$ print -l $path # zsh's tied array view of $PATH
Subshells#
( ... ) runs a group of commands in a forked subshell.
Variable and working-directory changes do not leak back to the
parent. Use it for a scoped cd or a temporary mutation
without writing a function.
$ ( cd /tmp; pwd ) # prints /tmp
$ pwd # parent cwd unchanged
$ x=1
$ ( x=99 ) # change happens in a subshell
$ echo "$x" # still 1
Command substitution $( ... ) runs in a subshell; a
backgrounded job & is one. Unlike bash, the last stage of a
pipeline runs in the current shell by default, so while
read loops can mutate outer state without lastpipe games.
Errors#
Every command returns an exit status (0 for success, non-zero
for failure) available in $?. && and || chain on it.
$ cmd && echo ok || echo failed
Strict mode for zsh maps closely to bash’s set -euo
pipefail but the option names are zsh-spelled.
$ #!/usr/bin/env zsh
$ setopt err_exit no_unset pipefail
$ setopt warn_create_global # catch typo-set globals
err_exit(-e) exits on the first command that returns non-zero.no_unset(-u) errors on unset variable references.pipefailmakes a pipeline fail if any stage fails.warn_create_globalwarns when a function assigns to a name that has not been declared local (the silent-clobber trap).
Traps run a handler on signal or exit.
$ tmp=$(mktemp)
$ trap 'rm -f "$tmp"' EXIT
$ trap 'echo interrupted; exit 130' INT TERM
Zsh adds named trap functions: define TRAPINT() or
TRAPEXIT() and zsh wires it to the matching signal. Cleaner
than the inline string form for non-trivial handlers.
$ TRAPEXIT() { rm -f "$tmp"; }
Modules#
Zsh has two module systems in addition to plain source.
source FILE (or .) reads and executes a file in the
current shell. autoload -Uz NAME defers loading; the file
named NAME on $fpath is parsed on first call. zmodload
zsh/<name> loads a compiled C module (zsh/datetime,
zsh/net/tcp, zsh/regex, zsh/zutil, zsh/mathfunc).
$ source ./lib/common.zsh
$ autoload -Uz add-zsh-hook # function lives on $fpath
$ zmodload zsh/datetime # exposes $EPOCHSECONDS
The defensive idiom that protects against double-source.
$ # lib/common.zsh
$ [[ -n "${COMMON_LOADED:-}" ]] && return 0
$ COMMON_LOADED=1
$ log() { print -u2 -- "[$(date +%FT%T)] $*" }
Runtime#
Zsh is a single-process, single-threaded interpreter. The
zsh binary parses each command, expands it, then either calls
a built-in directly or fork(2) + exec(2) an external
program. Compiled C modules attach more built-ins at runtime via
zmodload. No bytecode, no JIT, no garbage collector.
Practical implications.
Startup cost scales with
.zshenv+.zshrcwork.zprof(zmodload zsh/zprof) profiles it.Concurrency costs a process.
&and pipelines fork; each stage is a separate OS process. The last stage of a pipeline runs in the current shell.Variables die with the shell.
HISTFILEis the exception.No tail-call optimization. Deep recursion blows the stack; prefer iteration.
Library#
Zsh’s “standard library” has three layers. Built-ins live
inside the zsh binary (echo, print, read, printf,
cd, [[ ]], typeset, getopts, trap, setopt,
zstyle). Loadable modules ship with zsh and attach more
built-ins on demand (zsh/datetime, zsh/mathfunc,
zsh/net/tcp, zsh/regex, zsh/sched, zsh/zutil).
Coreutils is the external GNU package that ships ls,
cp, cut, sort, and the rest of the toolchain.
For the cataloging of coreutils, see Coreutils.
$ zmodload # list loaded modules
$ zmodload -L # show with load-on-demand mapping
$ which print # is it a builtin?
print is a shell builtin
Tasks#
Iterate over the lines of a file safely.
$ while IFS= read -r line; do
$ echo "$line"
$ done < input.txt
Read a file into an array.
$ lines=("${(@f)$(<input.txt)}")
$ print -- "$lines[1]" # 1-based indexing
Parse short and long flags with ``zparseopts``.
$ zmodload zsh/zutil
$ local -A opts
$ zparseopts -D -A opts -- h=flag_h v=flag_v f:=flag_f -file:=flag_file
$ (( ${+flag_h[1]} )) && { print "usage: ..."; exit 0 }
Trap a signal and clean up.
$ tmp=$(mktemp -d)
$ trap 'rm -rf "$tmp"' EXIT
$ trap 'print -u2 -- interrupted; exit 130' INT TERM
Run a step with a timeout.
$ timeout 30s curl -fsSL https://target
Background work and wait for all jobs.
$ for h in web01 db01 cache01; do
$ ssh "$h" 'uptime' &
$ done
$ wait
Compare two pipelines without temp files.
$ diff =(sort a.txt) =(sort b.txt) # zsh's "= command" form
Capture stdout and exit code at once.
$ out=$(cmd); rc=$?
(Unlike bash, the pipeline gotcha doesn’t apply; rc survives
the pipe.)
References#
man 1 zsh(the master page; cross-references the others).man 1 zshbuiltins,man 1 zshmodules,man 1 zshparam,man 1 zshexpn,man 1 zshoptions(the family that fills out this page).man 1 bash,man 1 dash(the POSIX neighbors for portability checks).Zsh for the surrounding zsh section (Setup, init files, line editing, completion, globbing).
Patterns for strict mode, traps, and the safer-script idioms.
Tools for the surrounding toolchain (
zprof,zmodload, debugging).The Terminal for the terminal-level view of how the shell is driven and how scope nests.
Coreutils for the GNU utilities every shell script reaches for.
zsh.sourceforge.io (the upstream manual).
A User’s Guide to the Z-Shell (Peter Stephenson).
Comments#
Lines beginning with
#are comments. There are no native block comments; the common workaround is a here-document fed to:(the no-op).