Shell Reference zsh 5.9 · bash 5.3 · POSIX sh · one language, three dialects

Three shells on one sheet, because they are three dialects of one language and the useful question is never “how does zsh do it” — it is “will this still work on the other machine?” Pages 1–3 are a working guide: what the three share, where they part company, startup files, quoting, expansion, arrays, globbing, tests, functions, redirection, job control, prompts, and the traps that only show up when a script moves from a laptop to a server. The remaining pages index every builtin, expansion form, test operator, glob and option, with a dot on each entry saying where it works. Type in the filter box to narrow the index; hover a clipped row for the full description.

Works in: all three — portable zsh + bash, not sh zsh only bash only same spelling, different behaviour
Sources: the Zsh manual (zshall, zshexpn, zshoptions, zshparam, zshmisc), the GNU Bash Reference Manual 5.3 and its NEWS/CHANGES, POSIX.1-2024 (IEEE Std 1003.1-2024) Shell & Utilities, the dash man page, Greg’s Wiki (BashFAQ / BashPitfalls), and shellcheck.net’s wiki. Version facts checked against macOS 26, Amazon Linux 2023 and Debian 13.

The Working Guide

What the three shells share, where they diverge, and which differences will actually cost you an evening

One Language, Three Dialects

zsh and bash are not rival languages. Both are supersets of the same POSIX shell grammar, and perhaps 85% of what you type works identically in either. The sheet is worth having because the remaining 15% is invisible until it fails — usually on the other machine, usually at 11pm.

Where they came from

ShellLine
sh (1977)Bourne’s shell. Its grammar, not its binary, became POSIX in 1992 — the common subset every later shell must honour.
bash (1989)GNU’s Bourne-Again Shell: POSIX plus arrays, [[, process substitution, local. The default login shell on essentially every Linux distribution.
zsh (1990)Written independently, then converged on ksh/bash syntax. Everything bash has plus far richer globbing and completion. macOS’s default login shell since Catalina (2019).
dash (1997)A deliberately small, fast, POSIX-only shell. Not for typing at — for running #!/bin/sh scripts quickly at boot. It is Debian’s /bin/sh.

sh is a specification, not a binary

#!/bin/sh is resolved by execve(2) against a path, and what sits at that path is a per-system packaging decision:

System/bin/sh really isConsequence
macOS 26bash 3.2 in POSIX mode ($BASH_VERSION is set, [[ works)Bashisms silently pass. A real dash is also installed at /bin/dash — test with it.
Amazon Linux 2023a symlink to bash 5.2Bashisms pass here too, and modern ones at that.
Debian 13 / UbuntudashThe same script now fails. This is where portability bugs surface.

The permissive host is the hazardous one: it accepts non-portable constructs and lets them propagate. A script authored where sh is bash carries no evidence of its own portability — only execution under dash constitutes a test.

What all three genuinely share

Pipelines and redirection, && / || / ; / &, if·while·until·for·case, functions, $(…), $((…)), ${var:-default} and friends, single/double quotes, * ? […] globs, exit status and $?, trap, set -e, here-documents, and the builtins cd export read shift test trap eval exec. Write only those and the script runs anywhere, including on a router.

Which Shell Am I In?

$SHELL does not answer it: it is the login shell recorded in the passwd database, inherited through the environment, and unchanged by starting any other shell. Interrogate the process, not the environment.

ps -p $$ -o comm=the honest answer: what process am Iecho $0usually right interactively; the script name in a scriptecho $SHELLyour login shell — often not the one you are typing intoecho $ZSH_VERSION $BASH_VERSIONset only by that shell; empty in dashecho ${.sh.version}ksh93 only — errors elsewhere

Branching on it inside a file

if [ -n "$ZSH_VERSION" ]; then
  setopt no_nomatch
elif [ -n "$BASH_VERSION" ]; then
  shopt -s nullglob
fi

Use the plain [ ] test here, not [[ ]]: the file may be sourced by a shell that has no [[, and the parse error happens before your check runs.

Interactive? Login?

[[ $- == *i* ]]interactive — $- holds the current option flags[[ -o login ]]zsh: is this a login shellshopt -q login_shellbash: the same question[ -t 0 ]portable: is stdin a terminal

The distinction is not academic — it decides which startup files ran, which is the next card.

Startup Files

This is the single largest practical difference between the two shells, and the reason a PATH edit “doesn’t take”.

zsh — always in this order

FileRead when
/etc/zshenv, ~/.zshenvEvery zsh, always, including scripts
~/.zprofileLogin shells only
~/.zshrcInteractive shells
~/.zloginLogin shells, after zshrc
~/.zlogoutLogin shell exit

bash — branching, not layered

KindReads
Login (interactive)/etc/profile, then the first of ~/.bash_profile, ~/.bash_login, ~/.profile that exists
Interactive, non-login~/.bashrc (and /etc/bash.bashrc on Debian)
Non-interactive scriptNothing — unless $BASH_ENV names a file
sh as sh/etc/profile + ~/.profile if login; else $ENV

Note the consequence of the branch: a login shell reads .bash_profile and not .bashrc. Hence the near-universal last line of a server’s .bash_profile:

[ -f ~/.bashrc ] && . ~/.bashrc

An SSH session that runs a command (ssh host 'cmd') is neither login nor interactive, so it reads neither file. If a remote command cannot find a program that works when you log in by hand, this is why — give the command an absolute path.

Where to put what

Kind of settingzshbash
Exported vars, PATH.zprofile (or .zshenv).bash_profile / .profile
Aliases, prompt, keybindings, completion.zshrc.bashrc

On macOS, Terminal.app opens every window as a login shell — unlike most Linux terminals, which open non-login ones. That single difference makes .zprofile matter locally and .bashrc matter remotely.

Words, Quoting & Splitting

The divergence that changes semantics rather than ergonomics: zsh does not perform word splitting on unquoted parameter expansions; bash and sh do. The same line yields a different argument vector.

files="a.txt b.txt"
for f in $files; do echo "[$f]"; done

# bash/sh: [a.txt] [b.txt]      ← split on IFS
# zsh:     [a.txt b.txt]        ← one word

zsh’s choice is the safer default, and it conceals missing quotes indefinitely — until the file is executed by bash, where a path containing whitespace becomes two arguments.

The invariant

Quote every expansion — "$var", "$@", "$(cmd)" — and steps 7 and 8 of the expansion pipeline are suppressed, at which point the two shells agree by construction. Unquoted is the special case requiring justification.

When you actually want splitting in zsh

${=var}split this expansion on $IFS${~var}glob-expand the resultsetopt sh_word_splitmake zsh behave like bash globally (blunt)emulate -L shfull sh emulation, scoped to one function

The quoting characters

FormMeans
'…'Literal. Nothing expands. Cannot contain a single quote at all.
"…"$, `, \ and (in history-expanding interactive shells) ! still act.
\cThe next character, literally. Outside quotes it also escapes a newline.
$'…'C-style escapes: $'\n', $'\t', $'\x41'. In all three shells since POSIX-2024, and long before that in zsh/bash.
$"…"Locale translation. bash and zsh; rarely used, easy to type by accident.

To put a single quote inside a single-quoted string, end the string, escape the quote and start again: 'it'\''s'. There is no escape inside single quotes in any shell.

"$@" versus "$*"

"$@"each argument stays a separate word — almost always what you want"$*"all arguments joined by the first char of $IFS into one word$@ unquotedsplits and globs; a bug waiting to happen

Expansion, In Order

The shell rewrites a command line in a fixed sequence before running anything. Knowing the order explains most “why didn’t that work” moments.

#StepNote
1Brace expansion {a,b}Not POSIX. Purely textual — happens before anything is looked up.
2Tilde ~, ~userOnly at the start of a word or after : in an assignment.
3Parameter $var, ${…}Left to right.
4Command $(…)Nested substitutions run inside-out.
5Arithmetic $((…))Integers only in sh/bash; zsh can do floats.
6Process substitution <(…)zsh + bash only; interleaved with 3–5.
7Word splitting on $IFSResults of 3–5 only, unquoted only, and not in zsh.
8Filename generation (globbing)Unquoted only.
9Quote removalThe quotes themselves finally disappear.

What the order explains

  • echo {1..$n} fails in bash — braces expand before $n does. (zsh does it in the order you hoped.) Use seq or a C-style for.
  • A glob inside a variable does not glob after substitution… except that step 8 runs after step 3, so $var containing * does glob in bash unless quoted. Another reason to quote.
  • ~ in PATH="$PATH:~/bin" stays literal — it is inside quotes. Write "$PATH:$HOME/bin".

Brace expansion

{a,b,c}list — file.{c,h}{1..10}range{1..10..2}range with step (bash 4+, zsh){a..e}character range{01..12}zero-paddedmv f.txt{,.bak}the classic: expands to f.txt f.txt.bak

Parameter Expansion

The shell’s string library. It runs in-process, so it beats calling sed for simple work — and the POSIX subset works everywhere.

Defaults and errors — all three shells

${v:-word}use word if v is unset or empty (v unchanged)${v-word}…only if unset; empty stays empty${v:=word}use word and assign it to v${v:?msg}error out with msg if unset/empty — a cheap assertion${v:+word}use word only if v is set${#v}length in characters

Trimming — all three shells

${v#pat}strip shortest match from the front${v##pat}strip longest from the front — ${path##*/} is basename${v%pat}strip shortest from the end — ${f%.*} drops the extension${v%%pat}strip longest from the end${path%/*}dirname, near enough

Substitution — zsh + bash, not sh

${v/pat/rep}replace first match${v//pat/rep}replace all${v/#pat/rep}replace only at the start${v/%pat/rep}only at the end${v^^} upper-case (bash 4+)${v:u}upper-case (zsh)${v,,}lower-case (bash 4+)${v:l}lower-case (zsh)

Case conversion is the sharpest split in this card: ${v^^} is bash-only, ${v:u} is zsh-only, and neither exists in sh. Portable answer: tr '[:lower:]' '[:upper:]'.

Slicing

${v:offset}from offset to end (bash; zsh with ksh_arrays caveats)${v:offset:len}substring${v: -3}last three — the space is required, or it parses as :-

Indirection

${!name}bash: value of the variable named by $name${(P)name}zsh: the same idea${!pre*}bash: names of all variables starting pre

Arrays

POSIX sh has no arrays. It has exactly one list — the positional parameters $1 $2 … "$@" — and that is the portable workaround. zsh and bash both have real arrays, and they disagree about where arrays start.

Operationzshbash
Createarr=(one two three) — identical
First element${arr[1]}${arr[0]}
All elements"${arr[@]}" — always quote it
Count${#arr[@]}
Slice${arr[2,4]}${arr[@]:1:3}
Appendarr+=(four)
Last${arr[-1]}${arr[-1]} (bash 4.3+)
Indices${(k)arr}${!arr[@]}
Join${(j:,:)arr}IFS=, ; echo "${arr[*]}"
Split a string${(s:,:)str}IFS=, read -ra arr <<< "$str"

zsh arrays are 1-indexed — the most common cause of an off-by-one when a bash script is pasted into zsh. setopt ksh_arrays switches zsh to 0-indexing, but it changes other behaviour too; prefer fixing the indices.

Associative arrays — zsh + bash 4, not sh

declare -A m   # bash
typeset -A m   # zsh (either works)
m[host]=example.com
echo "${m[host]}"
for k in "${(@k)m}"; do :; done  # zsh
for k in "${!m[@]}"; do :; done  # bash

bash 3.2 — still what /bin/bash is on macOS — has no associative arrays. A script using them fails there while working fine on any Linux server. Run it with the newer bash from Homebrew, or with zsh.

Globbing

The shared core is tiny; zsh’s extension is the largest single feature gap between the two shells.

Portable everywhere

*any string, not crossing /, not a leading dot?one character[abc]one of these[a-z]a range (locale-dependent — prefer classes)[!abc]none of these — [^abc] also works in bash/zsh[[:digit:]]POSIX character class

What happens when nothing matches

ShellDefault
sh / bashThe pattern is passed through literally — your loop runs once with *.txt as the filename.
zshError: no matches found, and the command does not run at all.
shopt -s nullglobbash: no match → the word disappearsshopt -s failglobbash: no match → error, like zshsetopt null_globzsh: the same as nullglobsetopt no_nomatchzsh: behave like bash’s defaultnoglob cmdzsh: run cmd without globbing its arguments

Consequence for remote patterns: scp host:'*.log' . must be quoted under zsh, or the local shell fails to match and aborts before scp is executed. The pattern was never meant for the local filesystem.

Recursion and extras

**/*.logzsh: recursive, always onshopt -s globstarbash 4+: enables **shopt -s extglobbash: ?()@()*()+()!() patternssetopt extended_globzsh: ^ negation, # repetition, (a|b) alternationshopt -s dotglobbash: include dotfilessetopt glob_dotszsh: the sameshopt -s nocaseglobbash: case-insensitive

zsh glob qualifiers — the killer feature

A parenthesised suffix filters matches by file attribute, with no find and no pipeline.

*(.)plain files only*(/)directories only*(@)symlinks*(*)executables*(.mh-2)files modified in the last 2 hours*(.Lm+10)files larger than 10 MB*(om[1,5])the 5 newest**/*(.)every regular file, recursively*(N)null_glob for this pattern only

There is no bash equivalent; the honest translation is find. Full list in the index.

Tests & Conditionals

There are three test syntaxes and they are not interchangeable.

FormWhat it is
[ … ]The test command, spelled with brackets. POSIX, works everywhere. Being a command, every operand is subject to word splitting — so every variable must be quoted.
[[ … ]]Shell syntax, in zsh and bash but not sh. No splitting or globbing inside, so quoting is optional; adds pattern and regex matching.
(( … ))Arithmetic evaluation. zsh + bash. Bare variable names, C operators, and — note — it is true when non-zero, the opposite of an exit status.

Why [[ is worth it when you can use it

[ $f = "a b" ]     # breaks if empty
[ "$f" = "a b" ]   # correct POSIX
[[ $f == "a b" ]]  # safe unquoted
[[ $f == *.txt ]]  # glob, unquoted
[[ $f =~ ^v[0-9]+$ ]]  # ERE regex

The operators

-e -f -d -L -r -w -x -sexists, file, dir, symlink, readable, writable, executable, non-empty-z -nstring empty / non-empty= != < >string compare (in [ ], escape \<)-eq -ne -lt -le -gt -geinteger compare — numbers only-nt -ot -efnewer than, older than, same file! -a -onot, and, or — prefer && / || between commands

The families are disjoint and only one direction fails loudly: -gt on a non-numeric operand is a diagnosed error, while > on integers performs a lexicographic comparison in which "9" > "10" holds. Silent wrong answers come from the second.

Combining

if [ -f "$f" ] && [ -r "$f" ]  # portable
if [[ -f $f && -r $f ]]        # zsh/bash
[ -d "$d" ] || mkdir -p "$d"
: "${1:?usage: FILE}"   # assertion

Loops & Case

Portable in all three

for f in *.txt; do echo "$f"; done
for a in "$@"; do :; done
while read -r line; do echo "$line"; done < file
until cmd; do sleep 1; done
case "$x" in
  a|b)  echo "a or b" ;;
  *.txt) echo "text" ;;
  *)    echo "other" ;;
esac

zsh + bash only

for ((i=0;i<10;i++))C-style loopfor i in {1..10}brace rangeselect x in a b cnumbered menu (also POSIX-optional)while read -r a b; do-r is POSIX; the field splitting is too

Reading a file the right way

while IFS= read -r line; do
  printf '%s\n' "$line"
done < "$file"

IFS= stops leading/trailing whitespace being trimmed; -r stops backslashes being eaten. Without both, you are quietly editing the data. Never for line in $(cat file) — that splits on words, not lines.

Loop control

break / continuewith an optional level: break 2shiftdrop $1, renumber the restwhile getopts "ab:" o; doportable option parsing

A pipeline’s last stage runs in a subshell in bash and sh, so cmd | while read… loses any variable it sets. zsh runs it in the current shell, so the same code works there and fails on the server. Portable fix: redirect from a file or use < <(cmd) in bash, or set shopt -s lastpipe.

Functions & Scope

name() { body; }          # POSIX
function name { body; }   # zsh + bash
function name() { }       # not POSIX

Functions take arguments exactly like scripts: $1, $@, $#. They do not take a parameter list.

Scope

WantWrite
Local variablelocal v=1 — in zsh, bash and dash, though not in POSIX itself
Typed / declaredtypeset -i n (zsh, bash) or declare -i n (bash, zsh)
Return a numberreturn 0255 — an exit status, not a value
Return a stringprintf '%s' "$x" and capture with $(…)
Export a functionexport -f name (bash only)

local is the most useful non-POSIX word in the language, and every shell you will meet supports it — including dash. It is safe in practice; shellcheck will still remind you it is not in the standard.

zsh: keeping a function’s options to itself

myfunc() {
  emulate -L zsh      # reverts on return
  setopt extended_glob
  ...
}

Autoloading

fpath+=(~/.zfunc); autoload -Uz myfnzsh: load from a file on first call. ~/lib/fns.shportable: just source ittype -a namewhat is this name — function, builtin, alias, filewhich -a namezsh: the same, with the function body

Redirection

Every process starts with three descriptors: 0 stdin, 1 stdout, 2 stderr. Redirection rebinds them, and order matters because each step is applied left to right.

> filestdout to file, truncating>> filestdout, appending< filestdin from file2> filestderr only2>&1stderr to wherever stdout is now> file 2>&1both to file — correct2>&1 > filestderr to the terminal, stdout to file — the classic mistake&> fileboth, in one token (zsh + bash, not sh)> /dev/null 2>&1discard everything, portably<> fileopen for read and writeexec 3< filekeep fd 3 open for latern>&-close descriptor n

Here-documents and here-strings

cat <<EOF      # expands $var, $(cmd)
  home is $HOME
EOF

cat <<'EOF'    # nothing expands
  literal $HOME
EOF

cat <<-EOF     # leading TABS go
	indented
	EOF

cat <<< "$var" # here-string

The delimiter’s quoting is the switch: unquoted, the body is expanded by the local shell before transmission — which is exactly wrong when the heredoc is a script being fed to ssh or a config file being generated.

Truncation and appending safely

set -o noclobbermake > refuse to overwrite>| fileoverwrite anyway, despite noclobbersort f > fdestroys f — the shell truncates before sort reads. Use a temp file or sponge.

Command Substitution & Pipes

$(cmd)substitute output; nests cleanly — use this`cmd`the old form; nesting needs escaping. Avoid.$(< file)read a file with no cat (zsh + bash)<(cmd)process substitution → a filename (zsh + bash)>(cmd)the same, for writing

Command substitution strips all trailing newlines, which is usually a kindness and occasionally a bug. It also runs in a subshell: variables set inside do not survive.

Process substitution earns its keep

diff <(sort a.txt) <(sort b.txt)
while read -r l; do :; done < <(find . -type f)

The second form is how bash avoids the subshell-in-a-pipeline problem from the loops card. Neither is available in sh — there, use temp files and trap to clean them up.

Pipelines and their exit status

a | bstatus is b’s only — a’s failure is invisibleset -o pipefailstatus is the last non-zero — zsh + bash, not sh${PIPESTATUS[@]}bash: every stage’s status${pipestatus[@]}zsh: the same, lower-casea |& bpipe stdout and stderr (zsh + bash 4)cmd 2>&1 | bthe portable spelling of that

Grouping

{ a; b; }run in the current shell — note the spaces and the final ;(a; b)run in a subshellcd inside cannot escape(cd /tmp && tar cf - .) | ...the standard use of that

Job Control

Nearly identical across the three, since it comes from the terminal driver rather than the shell.

cmd &start in the backgroundCtrl-Zsuspend the foreground job (SIGTSTP)bg / fgresume it in the background / foregroundjobs -llist jobs with PIDs%1 %+ %-job 1, current, previouskill %1signal a job rather than a PIDwaitwait for all children; wait $! for one$!PID of the most recent background jobdisown %1drop the job from the table (zsh + bash)nohup cmd &survive the terminal closing — portable

Job control is a terminal feature. A script has no job table, so fg and %1 are useless there — use wait and $! instead.

Traps

trap 'rm -f "$tmp"' EXIT   # any exit
trap 'echo stopped; exit 130' INT TERM
trap - EXIT                # remove it

EXIT is the one worth using by reflex — it is the only reliable place to clean up a temp file. Use signal names (INT, TERM, HUP), not numbers, and skip the SIG prefix for portability.

History & Line Editing

bash uses GNU readline; zsh uses its own ZLE. The default emacs keybindings are the same, and both read ~/.inputrc… except that zsh does not. The keys are in the index; the configuration differs:

zshbash
EditorZLE, configured with bindkeyreadline, configured in ~/.inputrc
vi modebindkey -vset -o vi
emacs modebindkey -eset -o emacs
History file$HISTFILE, needs SAVEHIST set or it saves nothing$HISTFILE, default ~/.bash_history
Share between windowssetopt share_historyshopt -s histappend + PROMPT_COMMAND='history -a'
Skip duplicatessetopt hist_ignore_all_dupsHISTCONTROL=ignoredups:erasedups
Don’t record a linesetopt hist_ignore_space + leading spaceHISTCONTROL=ignorespace

In zsh, HISTFILE selects the file and SAVEHIST the number of lines written to it. Setting only the former yields a working in-memory history that persists nothing.

History expansion — interactive shells

!!the previous command — sudo !!!$its last argument!*all its arguments!sshthe last command starting “ssh”!?log?the last containing “log”^old^newrerun, substituting once!!:gs/a/b/rerun with all a→b

This is why ! inside double quotes bites in an interactive shell but not in a script — expansion is off by default when non-interactive. set +H turns it off in bash for good.

Completion

The widest capability gap after globbing. bash has programmable completion; zsh has a completion system with its own function language, menus, descriptions and correction.

Turning it on

# zsh — in .zshrc
autoload -Uz compinit && compinit
zstyle ':completion:*' menu select
# case-insensitive matching:
zstyle ':completion:*' matcher-list \
  'm:{a-z}={A-Za-z}'

# bash — in .bashrc
bc=/etc/bash_completion
[ -r "$bc" ] && . "$bc"
# or, from Homebrew:
bc="$(brew --prefix)"/etc/profile.d/
bc="$bc"bash_completion.sh
[ -r "$bc" ] && . "$bc"
compinit -uzsh: skip the insecure-directory check that stalls first runcomplete -F _fn cmdbash: attach a completion functioncomplete -W "a b c" cmdbash: fixed word listcompdef _gnu_generic cmdzsh: guess from --helpCtrl-x ?zsh: what completion would fire here

Completion definitions are not portable between the two — a tool that ships both installs them in different directories ($fpath vs bash-completion/completions/). Nothing to port; just install the right one on each machine.

Prompts

Both use PS1 (zsh also calls it PROMPT), but the escape languages are entirely different.

Showszshbash
User%n\u
Host (short)%m\h
Working dir%~\w
Last component%1~\W
Time%* / %D{%H:%M}\t / \D{%H:%M}
# if root%#\$
Exit status%?via $? in PROMPT_COMMAND
Colour on%F{red}%f\[\e[31m\]\[\e[0m\]
Right-hand promptRPROMPT— none
PS1='%F{cyan}%n@%m%f %~ %# '
PS1='\[\e[36m\]\u@\h\[\e[0m\] \w \$ '

The escaping trap: in bash, non-printing sequences must be wrapped in \[ \], or the shell miscounts the line length and redraws long lines over themselves. zsh’s %F{} handles that for you — which is why a bash prompt pasted into zsh looks fine and a zsh prompt pasted into bash breaks.

Multi-line and dynamic prompts: zsh re-evaluates PROMPT each time only if setopt prompt_subst is set; bash runs PROMPT_COMMAND before each prompt.

Writing a Script That Survives

The shell’s defaults date from a single-user PDP-11 and are wrong for unattended execution: failures are ignored, unset names expand to nothing, and word splitting is on. Three options and a trap correct most of it.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
trap 'rm -rf "$tmp"' EXIT
tmp=$(mktemp -d)
SettingEffect, and its limit
set -eExit on any command with non-zero status. Does not fire inside a condition, on the left of &&/||, in a !-negated pipeline, or (in most shells) inside a function called from a condition context. It is a convenience, not an invariant.
set -uUnset parameter expansion is an error. Under bash < 4.4 this includes "$@" when there are no arguments — hence "${@:-}" in older scripts.
set -o pipefailPipeline status becomes the rightmost non-zero. Not POSIX; absent in dash. Without it set -e ignores every failure but the last stage’s.
IFS=$'\n\t'Removes space from the split set, so accidental splitting breaks on lines and fields rather than words. Belt-and-braces alongside quoting, not a substitute.

The shebang

#!/bin/sha promise of portability the interpreter will not enforce#!/bin/bashbash 3.2 on macOS, 5.x on Linux — the version varies more than the path#!/usr/bin/env bashfirst bash on PATH; picks up a newer one, loses absolute reproducibility#!/usr/bin/env zshfine for personal tooling; not present on a minimal server

The shebang line is read by execve(2), not the shell: one optional argument only, no PATH search, and a length cap (255 on Linux, 512 on macOS). env -S works around the single-argument limit on both.

Discipline that survives review

  • mktemp for every temp path; clean up in an EXIT trap, which also covers set -e exits.
  • printf, never echo, for anything containing user data — echo’s handling of -n and backslashes is implementation-defined and differs between the three shells and /bin/echo.
  • Quote every expansion; use arrays for argument lists so quoting survives.
  • cd -- "$d" and rm -- "$f": -- ends option parsing, so a filename beginning with - is data.
  • Run shellcheck in CI. It knows the dialect differences catalogued here and checks the shebang you declared.

The Portability Column

What to avoid when the shebang says #!/bin/sh — each of these parses fine under a bash-provided sh and fails under dash.

Not portablePortable equivalent
[[ … ]][ … ] with every operand quoted
(( n > 3 ))[ "$n" -gt 3 ], or : $((n = n + 1)) for assignment
ArraysPositional parameters via set -- a b c; "$@" to iterate
localUniversally implemented, never standardised. Use it and note the assumption.
function f {}f() {}
&> f, |&> f 2>&1, 2>&1 |
<<< "$s"printf '%s\n' "$s" |, or a here-document
<(cmd)A temp file plus an EXIT trap; or a named pipe from mkfifo
${v^^}, ${v:u}tr '[:lower:]' '[:upper:]'
${v/a/b}sed 's/a/b/', or compose # and % trims
echo -e, echo -nprintf
source f. f
pipefailCheck $? per stage, or restructure to avoid the pipeline
{1..10}seq 1 10, or a while loop with $((…))
read -a, mapfilewhile read with IFS set per invocation
trap … ERR, RETURN, DEBUGOnly EXIT and real signals are POSIX

Verifying rather than hoping

dash -n script.shparse only — catches syntax-level bashisms instantlyshellcheck -s sh script.shthe dialect-aware checkcheckbashisms script.shDebian’s devscripts; purpose-built for exactly thisbash --posixweaker than dash: bash keeps most extensions

bash --posix is not a portability test. It disables a handful of behaviours and keeps [[, arrays and process substitution — anything you want caught. Parse with dash or do not claim portability.

Porting Between the Three

Ordered by how much time each costs when it is missed.

MovingWhat changes under you
zsh → bashWord splitting turns on. Every unquoted $var that held a space is now several arguments. Quoting everything makes this a non-event.
zsh → bashArrays re-base to 0. No error, no warning; loops just skip the first element or run one short.
zsh → bashA failed glob no longer aborts — it becomes a literal argument, so the loop body runs once with a pattern where a filename should be.
zsh → bash** is inert without shopt -s globstar; glob qualifiers have no analogue at all.
bash → zshPS1 escapes are meaningless; the prompt renders as literal \u\h.
bash → zshcmd | while read now mutates the enclosing shell’s variables, because zsh does not fork the last pipeline stage. Code written to work around the subshell silently changes meaning.
bash → zsh${v^^} and ${!v} are parse errors or mean something else.
either → shEverything in the portability column — and note that #!/bin/sh hides the breakage on macOS and Amazon Linux, where sh is bash.
macOS bash → Linux bashNothing breaks; the reverse does. macOS’s /bin/bash is 3.2 (frozen at the GPLv3 boundary), so declare -A, ${v^^}, mapfile, globstar and negative indices are all absent.
any → anyThe utilities differ more than the shells: BSD vs GNU sed -i, date -d, readlink -f, stat, xargs -r. Most cross-platform script bugs live here, not in the shell.

Making one file serve both interactive shells

# ~/.shellrc  — sourced from .zshrc and .bashrc
export EDITOR=nvim
alias ll='ls -lah'
case "$(ps -p $$ -o comm= 2>/dev/null)" in
  *zsh*)  setopt hist_ignore_dups ;;
  *bash*) shopt -s histappend ;;
esac

Aliases, exports and functions written in the common subset move cleanly. Keybindings, completion and prompts do not — keep those in the shell-specific file rather than guarding them line by line.

Traps & Idioms

Behaviour that looks like a bug and is not

SymptomMechanism
Variable set in a loop is empty afterwardsThe loop was the last stage of a pipeline, which bash and sh run in a forked subshell. zsh does not. lastpipe, process substitution, or a redirect avoids the fork.
cd in a script leaves you where you startedThe script is a child process; chdir(2) does not propagate upward. source it, or have it print a path the caller consumes.
sudo cmd > /root/f → permission deniedThe redirection is performed by the calling shell before sudo execs. sudo sh -c 'cmd > /root/f', or pipe to sudo tee.
x=1 not visible to a childShell variables are not environment variables until export; the environment is copied at execve time only.
var = value is “command not found”Assignment is lexical: no whitespace around =, or it parses as a command and its arguments.
Alias ignored inside a scriptAliases are expanded during parsing and disabled in non-interactive shells. Functions are the scriptable unit.
An alias defined and used in the same block does nothingThe whole compound command was parsed before the alias existed.
$(cmd) loses trailing blank linesCommand substitution strips all trailing newlines by definition. Append a sentinel and remove it if they matter.
A filename with a newline breaks the loopOnly NUL cannot appear in a path. find -print0 | xargs -0, or find -exec … +.
rm -rf "$d/"* with $d unsetExpands to /*. set -u, or ${d:?}, converts this into an error instead of an incident.

Idioms worth keeping

cd -back to $OLDPWD!!:pprint a history expansion without running it: "${v:=default}"assign a default with no side effectcmd || truetolerate a failure under set -ecommand -v cmd >/dev/nullportable “is it installed” — not whichexec 2> err.logredirect the rest of the scripttrap 'echo $LINENO' ERRbash: cheap tracingset -x / set +xtrace on/off; PS4='+ $LINENO: ' to annotate${0##*/}the script’s own name for usage textreadonly vconstants; assignment afterwards is an error

A Word on fish

The fourth shell you might reasonably meet, and the only widely used one that is not a Bourne derivative. It abandons POSIX compatibility deliberately, which buys a coherent language and costs you every snippet on the internet.

Bourne familyfish
v=1set v 1
export v=1set -x v 1
$(cmd)(cmd)
if [ -f x ]; then … fiif test -f x; … end
arr=(a b); scalars and lists distinctEvery variable is a list; no word splitting anywhere
&&, ||; and, ; or
Configuration in .bashrc/.zshrc~/.config/fish/config.fish; options set by command, not file

What it gets right, out of the box and without a framework: syntax highlighting as you type, autosuggestion from history, and completions generated from man pages.

The practical position: fish is a pleasant interactive shell and a poor scripting target — you cannot source a POSIX profile, and #!/bin/sh knowledge does not transfer. If it appeals, run it as your interactive shell while leaving /bin/sh and every script alone. Note that zsh with zsh-autosuggestions and zsh-syntax-highlighting reaches the same interactive experience without leaving the family.

The Index

Every builtin, expansion, operator, glob and option — dotted with the shells it works in. Filter above; hover a clipped row for the full note.

Builtins

by job

Navigation & files

cdchange directory
pwdprint working directory
pushd / popd / dirsdirectory stack
umaskdefault permission mask

Variables & environment

exportmark for the environment
unsetremove a name
readonlymake immutable
shiftrenumber positionals
setoptions and positionals
typeset / declaredeclare with attributes
localfunction-scoped variable
mapfile / readarrayfile into an array

Control & evaluation

evalreparse and execute
execreplace the shell
exitterminate
returnleave a function or sourced file
traphandle signals and EXIT
waitawait children
true / false / :fixed status
test / [evaluate an expression
[[conditional expression
((arithmetic evaluation

Input & output

echowrite a line
printfformatted output
readread a line into names
read -t / -s / -ntimeout, silent, n chars
read -aread into an array

Introspection

commandrun bypassing functions
typeclassify a name
whence / whichzsh classification
hashcommand location cache
timesshell and child CPU time
getoptsparse short options

Job control

jobslist jobs
killsend a signal
fg / bgforeground / background
disowndrop from the job table
suspendstop this shell

Aliases, history & completion

alias / unaliasdefine a text alias
historythe history list
fcfix command
complete / compgenprogrammable completion
compdef / zstylezsh completion system
bindkeyzsh key bindings
bindbash readline bindings

Options & modules

setopt / unsetoptzsh options
shoptbash options
emulateswitch dialect
zmodloadload a zsh module
autoloadlazy function loading
ulimitresource limits

Parameter Expansion

all forms

Defaults & assertions

${v:-word}word if unset or empty
${v-word}word only if unset
${v:=word}assign word if unset/empty
${v:?message}error if unset or empty
${v:+word}word if v is set

Length & trimming

${#v}length in characters
${#arr[@]}number of elements
${v#pat}strip shortest leading match
${v##pat}strip longest leading match
${v%pat}strip shortest trailing match
${v%%pat}strip longest trailing match

Substitution & case

${v/pat/rep}replace first match
${v//pat/rep}replace every match
${v/#pat/rep}replace at the start only
${v/%pat/rep}replace at the end only
${v^^} ${v,,}upper / lower case
${v:u} ${v:l}upper / lower case

Slicing & indirection

${v:off:len}substring
${arr[@]:1:3}array slice
${!name}indirect reference
${(P)name}indirect reference
${!prefix*}names with a prefix
${v@Q}quoted for reuse

zsh flags

${(s:,:)v}split on a delimiter
${(j:,:)arr}join with a delimiter
${(f)v}split on newlines
${(@)arr}preserve array-ness
${(k)h} ${(v)h}keys / values
${(U)v} ${(L)v}upper / lower
${(q)v}quote for reuse
${=v}force word splitting
${~v}force globbing

Special Parameters

set by the shell

Positional & status

$0shell or script name
$1 … $9, ${10}positional parameters
$#number of positionals
"$@"all positionals, separately
"$*"all positionals, joined
$?last exit status
$$PID of the shell
$!PID of the last background job
$-current option flags

Environment

$HOME $PATH $PWD $OLDPWDthe standard set
$IFSfield separators
$PS1 $PS2 $PS4prompts
$SHELLlogin shell from passwd
$TERM $LANG $LC_ALLterminal and locale
$RANDOMpseudo-random 0–32767
$SECONDSshell uptime
$LINENO $FUNCNAMEcurrent line / function

Shell-specific

$BASH_VERSION $BASH_SOURCEbash identity and file path
$ZSH_VERSION $ZSH_NAMEzsh identity
$PIPESTATUSstatus of each pipeline stage
$pipestatusstatus of each pipeline stage
$fpath $pathzsh tied arrays
$BASH_ENVstartup file for non-interactive bash
$ZDOTDIRwhere zsh looks for dotfiles
$PROMPT_COMMANDrun before each prompt

Test Operators

[ ] and [[ ]]

File tests — portable

-e fexists
-f fregular file
-d fdirectory
-L f / -h fsymbolic link
-r -w -x freadable, writable, executable
-s fsize greater than zero
-p fnamed pipe
-S fsocket
-b f / -c fblock / character device
-t fddescriptor is a terminal
-u -g -k fsetuid, setgid, sticky
-O f / -G fowned by effective UID / GID

File comparison

f1 -nt f2newer than
f1 -ot f2older than
f1 -ef f2same file

Strings

-z sempty
-n snon-empty
s1 = s2equal
s1 != s2not equal
s < s2 / s > s2lexical order
str == patternglob match
str =~ regexERE match

Integers

a -eq bequal
a -ne bnot equal
a -lt -le -gt -ge bordering

Logic

! exprnegation
e1 -a e2 / -o e2and / or inside [ ]
e1 && e2 / e1 || e2and / or inside [[ ]]
-o optnameshell option is set
-v namevariable is set

Globs & Patterns

filename generation

Portable

*any string
?any single character
[abc]any listed character
[a-z]a range
[!abc]none of these
[[:alpha:]]character class

bash extended — shopt -s extglob

?(pat)zero or one
*(pat)zero or more
+(pat)one or more
@(a|b)exactly one of
!(pat)anything but
**recursive descent

zsh extended — setopt extended_glob

**/recursive descent
^patanything but
pat~exclexcept
(a|b)alternation
x# x##zero-or-more, one-or-more
(#i)patcase-insensitive
<1-100>numeric range

Behaviour switches

nullglob / failglobno match: vanish / error
null_glob / no_nomatchthe same, zsh spelling
dotglob / glob_dotsinclude dotfiles
nocaseglob / no_case_globignore case
numeric_glob_sortsort numerically
noglob cmddisable globbing for one command

zsh Glob Qualifiers

the feature with no equivalent

Type

(.)regular files
(/)directories
(@)symbolic links
(*)executable files
(p) (s) (=)FIFO, socket, socket-ish
(F)non-empty directories

Time

(mh-2)modified in the last 2 hours
(md+7)modified more than 7 days ago
(am0)accessed within the current minute

Size

(Lm+10)larger than 10 MB
(Lk-4)smaller than 4 KB
(L0)exactly empty

Ownership & permission

(u:root:)owned by that user
(U)owned by me
(f:600:)exact permissions
(W) (R) (X)world writable / readable / executable

Sorting & selection

(om)sort by mtime, newest first
(om[1,5])the five newest
(N)null_glob here only
(D)include dotfiles here only
(-.)follow symlinks, then test
(:t) (:h) (:r) (:e)tail, head, root, extension

Redirection

descriptors

Portable

cmd > fstdout to f, truncating
cmd >> fstdout appended
cmd < fstdin from f
cmd 2> fstderr to f
cmd > f 2>&1both to f
cmd 2>&1 > fstderr to the terminal
cmd > /dev/null 2>&1discard everything
cmd <> fopen read-write
exec 3< fhold a descriptor open
n>&-close a descriptor
cmd <<EOFhere-document

zsh + bash

cmd &> fboth streams to f
cmd &>> fboth, appended
cmd <<< "$s"here-string
cmd < <(other)process substitution as input
cmd > >(other)process substitution as output
a |& bpipe stdout and stderr
cmd > f1 > f2implicit tee

Control

set -o noclobberrefuse to overwrite with >
>| foverwrite despite noclobber
exec > log 2>&1redirect the rest of the script

Arithmetic

$(( )) and friends

Forms

$((expr))arithmetic expansion
((expr))arithmetic command
let "x = 1 + 2"older spelling
: $((i = i + 1))portable increment
float f; (( f = 1.0/3 ))floating point

Operators

+ - * / %arithmetic
**exponentiation
<< >> & | ^ ~bit operations
< <= > >= == !=comparison, yielding 1 or 0
&& || !logical, short-circuiting
c ? a : bternary
++ -- += -= *= /=increment and compound assignment
0x1f 010 2#1010hex, octal, base-n literals

set / setopt / shopt

behaviour switches

set — POSIX, all three

set -eexit on error
set -uunset variable is an error
set -xtrace execution
set -nparse without executing
set -fdisable globbing
set -Cnoclobber
set -mjob control
set -vecho input as read
set -o vi / emacsediting mode
set -o pipefailpipeline fails if any stage does
set -- a b creplace the positionals

bash — shopt

shopt -s globstarenable **
shopt -s extglobextended patterns
shopt -s nullglobunmatched glob vanishes
shopt -s failglobunmatched glob errors
shopt -s dotglobglobs match dotfiles
shopt -s nocasematchcase-insensitive [[ and case
shopt -s lastpiperun the last pipeline stage in this shell
shopt -s histappendappend rather than overwrite history
shopt -s checkwinsizeupdate LINES and COLUMNS
shopt -s autocda bare directory means cd
shopt -s cdspellcorrect minor cd typos

zsh — setopt

setopt extended_globextended patterns
setopt null_globunmatched glob vanishes
setopt no_nomatchdo not error on no match
setopt sh_word_splitsplit unquoted expansions
setopt ksh_arrays0-indexed arrays
setopt auto_cda bare directory means cd
setopt auto_pushdevery cd pushes the stack
setopt share_historylive history across terminals
setopt hist_ignore_all_dupsdrop older duplicates
setopt hist_ignore_spaceskip lines starting with a space
setopt correct / correct_allspelling correction
setopt prompt_substexpand $( ) in the prompt
setopt interactive_commentsallow # comments interactively

Prompt Escapes

PS1 / PROMPT

zsh

%n %m %Muser, short host, full host
%~ %d %1~cwd with ~, full cwd, last component
%# %(!.#.$)root marker, conditional
%? %(?..%F{red}%?%f)exit status, shown only on failure
%* %T %D{fmt}time, hh:mm, strftime
%F{colour} … %fforeground colour on/off
%j %L %ijobs, shell level, line number
RPROMPTright-hand prompt

bash

\u \h \Huser, short host, full host
\w \Wcwd with ~, last component
\$# for root, $ otherwise
\t \T \@ \dtime forms and date
\[ … \]wrap non-printing sequences
\e[31m … \e[0mANSI colour
\j \! \#jobs, history number, command number
PROMPT_COMMANDcommand run before each prompt

Line-Editing Keys

emacs mode, both shells

Movement

Ctrl-a / Ctrl-estart / end of line
Ctrl-f / Ctrl-bforward / back one character
Alt-f / Alt-bforward / back one word
Ctrl-xxtoggle between start and cursor

Editing

Ctrl-wdelete the previous word
Ctrl-udelete to start of line
Ctrl-kdelete to end of line
Ctrl-yyank back what was killed
Ctrl-t / Alt-ttranspose characters / words
Ctrl-_undo
Ctrl-lclear the screen

History & completion

Ctrl-rreverse incremental search
Ctrl-sforward search
Up / Ctrl-pprevious line
Tabcomplete
Alt-.insert the last argument
Ctrl-c / Ctrl-dabandon line / EOF
Ctrl-x Ctrl-eedit the line in $EDITOR

History Expansion

interactive shells

Event

!!the previous command
!n / !-2by number / relative
!strlast command starting with str
!?str?last command containing str
!#the current line so far

Word

!$ / !^ / !*last, first, all arguments
!!:2 / !!:2-3word 2 / a range

Modifier

:h :t :r :edirname, basename, root, extension
:pprint without executing
:s/a/b/ :gs/a/b/substitute once / globally
^old^newrerun with one substitution
:q :xquote the result

Signals & Exit Codes

what a status means

Common signals

HUP (1)terminal closed
INT (2)Ctrl-c
QUIT (3)Ctrl-\, with a core dump
KILL (9)unblockable termination
TERM (15)polite termination
STOP / TSTP (17,18)suspend
CONT (19)resume
USR1 / USR2application-defined

Pseudo-signals for trap

EXIT (0)the shell is exiting
ERRa command failed
DEBUG / RETURNbefore each command / on function return

Exit status conventions

0success
1general failure
2usage error
126found but not executable
127command not found
128+nkilled by signal n
255out of range