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"

Comments#

Lines beginning with # are comments. There are no native block comments; the common workaround is a here-document fed to : (the no-op).

$ : <<'COMMENT'
$ block of commentary
$ that zsh will ignore
$ COMMENT

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 (2**10 is 1024)

++ --

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 ([[ ]] or (( )))

Meaning

== (or =)

-eq

Equal

!=

-ne

Not equal

<

-lt

Less than

<=

-le

Less than or equal

>

-gt

Greater than

>=

-ge

Greater than or equal

-z X

String X is empty

-n X

String X is non-empty

=~

String matches regex (captures in $match array)

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

-e PATH

Path exists (any type)

-f PATH

Regular file

-d PATH

Directory

-L PATH / -h PATH

Symlink (does not follow)

-r / -w / -x PATH

Caller has read / write / execute permission

-s PATH

File exists and is non-empty

-O PATH / -G PATH

Owned by effective UID / GID

-u / -g / -k PATH

SUID / SGID / sticky bit set

A -nt B / A -ot B

A newer / older than B

A -ef B

A and B are the same file

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

NAME=value

Bind NAME to a string

NAME+=value

Append to a string, or append to an array

(( NAME = expr ))

Arithmetic evaluation, no $ needed

(( NAME += n ))

Compound arithmetic

typeset -i NAME

Integer-attributed; bare NAME=expr evaluates

typeset -F NAME

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

${var}

Plain expansion

${var:-default}

Use default if var is unset or empty

${var:=default}

Same, and assign default back

${var:?msg}

Error out with msg if var is unset

${var:offset:length}

Substring

${#var}

Length

${var#pat} / ${var##pat}

Strip shortest / longest prefix

${var%pat} / ${var%%pat}

Strip shortest / longest suffix

${var/pat/repl} / ${var//pat/repl}

Replace first / every occurrence

Zsh-only flag forms inside ${(...)var}.

Flag

Effect

(U) / (L)

Uppercase / lowercase the whole value

(C)

Capitalise each word

(j:str:)

Join an array with str

(s:str:)

Split on str (use (@s:str:) for arrays)

(f)

Split on newlines

(F)

Join with newlines

(o) / (O)

Sort ascending / descending

(u)

Remove duplicates

(n)

Numeric sort (with o or O)

(P)

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

[abc] / [a-z]

One character from the set

[^abc]

One character not in the set

**/

Recursive subdirectory match

<n-m>

Numeric range (<1-100> matches 1..100)

(p1|p2)

Alternation

^pat / *~pat

Exclusion (setopt EXTENDED_GLOB)

pat(N)

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

{a,b,c}

a b c

{1..5}

1 2 3 4 5

{1..10..2}

1 3 5 7 9

{a..f}

a b c d e f

$ 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

if cmd; then ... fi

Any command can drive an if; the exit code is the test.

if [[ cond ]]; then ... fi

String, glob, regex, and file tests. The default.

if (( expr )); then ... fi

Numeric / arithmetic tests. No $ needed inside.

cmd && next / cmd || next

One-line guard for a single step.

for x in LIST

Walk a fixed or globbed list of words.

for x (LIST)

Zsh shorthand, no do / done required.

for ((i=0;i<n;i++))

Arithmetic counter, C-style.

while cond

Loop while the test stays true.

until cond

Loop while the test stays false.

repeat N

Run the body N times (zsh-only).

case x in pat) ... esac

One value, many patterns. First match wins.

select x in LIST

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

$1, $2, …

Positional arguments

$@

All arguments (array; one element per token)

$#

Argument count

$0

Script name (or function name inside a function; FUNCTION_ARGZERO controls)

$$

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.

  • pipefail makes a pipeline fail if any stage fails.

  • warn_create_global warns 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 + .zshrc work. 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. HISTFILE is 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).