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.
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.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.
| Shell | Line |
|---|---|
| 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. |
#!/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 is | Consequence |
|---|---|---|
| macOS 26 | bash 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 2023 | a symlink to bash 5.2 | Bashisms pass here too, and modern ones at that. |
| Debian 13 / Ubuntu | dash | The 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.
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.
$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.
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.
$- 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 terminalThe distinction is not academic — it decides which startup files ran, which is the next card.
This is the single largest practical difference between the two shells, and the reason a PATH edit “doesn’t take”.
| File | Read when |
|---|---|
/etc/zshenv, ~/.zshenv | Every zsh, always, including scripts |
~/.zprofile | Login shells only |
~/.zshrc | Interactive shells |
~/.zlogin | Login shells, after zshrc |
~/.zlogout | Login shell exit |
| Kind | Reads |
|---|---|
| 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 script | Nothing — 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.
| Kind of setting | zsh | bash |
|---|---|---|
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.
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.
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.
$IFS${~var}glob-expand the resultsetopt sh_word_splitmake zsh behave like bash globally (blunt)emulate -L shfull sh emulation, scoped to one function| Form | Means |
|---|---|
'…' | Literal. Nothing expands. Cannot contain a single quote at all. |
"…" | $, `, \ and (in history-expanding interactive shells) ! still act. |
\c | The 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.
$IFS into one word$@ unquotedsplits and globs; a bug waiting to happenThe shell rewrites a command line in a fixed sequence before running anything. Knowing the order explains most “why didn’t that work” moments.
| # | Step | Note |
|---|---|---|
| 1 | Brace expansion {a,b} | Not POSIX. Purely textual — happens before anything is looked up. |
| 2 | Tilde ~, ~user | Only at the start of a word or after : in an assignment. |
| 3 | Parameter $var, ${…} | Left to right. |
| 4 | Command $(…) | Nested substitutions run inside-out. |
| 5 | Arithmetic $((…)) | Integers only in sh/bash; zsh can do floats. |
| 6 | Process substitution <(…) | zsh + bash only; interleaved with 3–5. |
| 7 | Word splitting on $IFS | Results of 3–5 only, unquoted only, and not in zsh. |
| 8 | Filename generation (globbing) | Unquoted only. |
| 9 | Quote removal | The quotes themselves finally disappear. |
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.$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".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.bakThe shell’s string library. It runs in-process, so it beats calling sed for simple work — and the POSIX subset works everywhere.
${path##*/} is basename${v%pat}strip shortest from the end — ${f%.*} drops the extension${v%%pat}strip longest from the end${path%/*}dirname, near enoughCase 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:]'.
ksh_arrays caveats)${v:offset:len}substring${v: -3}last three — the space is required, or it parses as :-$name${(P)name}zsh: the same idea${!pre*}bash: names of all variables starting prePOSIX 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.
| Operation | zsh | bash |
|---|---|---|
| Create | arr=(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} |
| Append | arr+=(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.
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.
The shared core is tiny; zsh’s extension is the largest single feature gap between the two shells.
/, 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| Shell | Default |
|---|---|
| sh / bash | The pattern is passed through literally — your loop runs once with *.txt as the filename. |
| zsh | Error: no matches found, and the command does not run at all. |
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.
**shopt -s extglobbash: ?()@()*()+()!() patternssetopt extended_globzsh: ^ negation, # repetition, (a|b) alternationshopt -s dotglobbash: include dotfilessetopt glob_dotszsh: the sameshopt -s nocaseglobbash: case-insensitiveA parenthesised suffix filters matches by file attribute, with no find and no pipeline.
There is no bash equivalent; the honest translation is find. Full list in the index.
There are three test syntaxes and they are not interchangeable.
| Form | What 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. |
[ $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
[ ], escape \<)-eq -ne -lt -le -gt -geinteger compare — numbers only-nt -ot -efnewer than, older than, same file! -a -onot, and, or — prefer && / || between commandsThe 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.
if [ -f "$f" ] && [ -r "$f" ] # portable
if [[ -f $f && -r $f ]] # zsh/bash
[ -d "$d" ] || mkdir -p "$d"
: "${1:?usage: FILE}" # assertion
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
-r is POSIX; the field splitting is toowhile 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.
break 2shiftdrop $1, renumber the restwhile getopts "ab:" o; doportable option parsingA 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.
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.
| Want | Write |
|---|---|
| Local variable | local v=1 — in zsh, bash and dash, though not in POSIX itself |
| Typed / declared | typeset -i n (zsh, bash) or declare -i n (bash, zsh) |
| Return a number | return 0–255 — an exit status, not a value |
| Return a string | printf '%s' "$x" and capture with $(…) |
| Export a function | export -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.
myfunc() {
emulate -L zsh # reverts on return
setopt extended_glob
...
}
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.
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.
> refuse to overwrite>| fileoverwrite anyway, despite noclobbersort f > fdestroys f — the shell truncates before sort reads. Use a temp file or sponge.cat (zsh + bash)<(cmd)process substitution → a filename (zsh + bash)>(cmd)the same, for writingCommand 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.
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.
;(a; b)run in a subshell — cd inside cannot escape(cd /tmp && tar cf - .) | ...the standard use of thatNearly identical across the three, since it comes from the terminal driver rather than the shell.
wait $! for one$!PID of the most recent background jobdisown %1drop the job from the table (zsh + bash)nohup cmd &survive the terminal closing — portableJob control is a terminal feature. A script has no job table, so fg and %1 are useless there — use wait and $! instead.
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.
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:
| zsh | bash | |
|---|---|---|
| Editor | ZLE, configured with bindkey | readline, configured in ~/.inputrc |
| vi mode | bindkey -v | set -o vi |
| emacs mode | bindkey -e | set -o emacs |
| History file | $HISTFILE, needs SAVEHIST set or it saves nothing | $HISTFILE, default ~/.bash_history |
| Share between windows | setopt share_history | shopt -s histappend + PROMPT_COMMAND='history -a' |
| Skip duplicates | setopt hist_ignore_all_dups | HISTCONTROL=ignoredups:erasedups |
| Don’t record a line | setopt hist_ignore_space + leading space | HISTCONTROL=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.
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→bThis 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.
The widest capability gap after globbing. bash has programmable completion; zsh has a completion system with its own function language, menus, descriptions and correction.
# 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"
--helpCtrl-x ?zsh: what completion would fire hereCompletion 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.
Both use PS1 (zsh also calls it PROMPT), but the escape languages are entirely different.
| Shows | zsh | bash |
|---|---|---|
| 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 prompt | RPROMPT | — 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.
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)
| Setting | Effect, and its limit |
|---|---|
set -e | Exit 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 -u | Unset parameter expansion is an error. Under bash < 4.4 this includes "$@" when there are no arguments — hence "${@:-}" in older scripts. |
set -o pipefail | Pipeline 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. |
PATH; picks up a newer one, loses absolute reproducibility#!/usr/bin/env zshfine for personal tooling; not present on a minimal serverThe 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.
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.cd -- "$d" and rm -- "$f": -- ends option parsing, so a filename beginning with - is data.shellcheck in CI. It knows the dialect differences catalogued here and checks the shebang you declared.What to avoid when the shebang says #!/bin/sh — each of these parses fine under a bash-provided sh and fails under dash.
| Not portable | Portable equivalent |
|---|---|
[[ … ]] | [ … ] with every operand quoted |
(( n > 3 )) | [ "$n" -gt 3 ], or : $((n = n + 1)) for assignment |
| Arrays | Positional parameters via set -- a b c; "$@" to iterate |
local | Universally 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 -n | printf |
source f | . f |
pipefail | Check $? per stage, or restructure to avoid the pipeline |
{1..10} | seq 1 10, or a while loop with $((…)) |
read -a, mapfile | while read with IFS set per invocation |
trap … ERR, RETURN, DEBUG | Only EXIT and real signals are POSIX |
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.
Ordered by how much time each costs when it is missed.
| Moving | What changes under you |
|---|---|
| zsh → bash | Word splitting turns on. Every unquoted $var that held a space is now several arguments. Quoting everything makes this a non-event. |
| zsh → bash | Arrays re-base to 0. No error, no warning; loops just skip the first element or run one short. |
| zsh → bash | A 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 → zsh | PS1 escapes are meaningless; the prompt renders as literal \u\h. |
| bash → zsh | cmd | 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 → sh | Everything in the portability column — and note that #!/bin/sh hides the breakage on macOS and Amazon Linux, where sh is bash. |
| macOS bash → Linux bash | Nothing 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 → any | The 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. |
# ~/.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.
| Symptom | Mechanism |
|---|---|
| Variable set in a loop is empty afterwards | The 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 started | The 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 denied | The 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 child | Shell 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 script | Aliases 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 nothing | The whole compound command was parsed before the alias existed. |
$(cmd) loses trailing blank lines | Command substitution strips all trailing newlines by definition. Append a sentinel and remove it if they matter. |
| A filename with a newline breaks the loop | Only NUL cannot appear in a path. find -print0 | xargs -0, or find -exec … +. |
rm -rf "$d/"* with $d unset | Expands to /*. set -u, or ${d:?}, converts this into an error instead of an incident. |
$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 errorThe 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 family | fish |
|---|---|
v=1 | set v 1 |
export v=1 | set -x v 1 |
$(cmd) | (cmd) |
if [ -f x ]; then … fi | if test -f x; … end |
arr=(a b); scalars and lists distinct | Every 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.