sh (POSIX) vs Zsh
macOS has shipped Zsh as `/bin/zsh` since Catalina (2019) but its `/bin/sh` is still strict POSIX. This compare is the bridge: which zsh features survive a switch back to `sh`, and what the macOS-trained developer trips over in CI.
Summary
`sh` is the POSIX 2017 specification — implemented on most systems by `dash`, `busybox ash`, or `bash --posix`. Zsh is a feature-superset of sh, designed in 1990 with explicit "user-friendly POSIX" goals. Zsh is much closer to bash than to fish, and most bash scripts run unchanged under zsh — but the divergences from POSIX are wider than bash's.
Beyond bash, zsh adds: glob qualifiers (`*(.)` for files only, `*(/.)` for directories), recursive globs `**/*.txt`, the `=(cmd)` process-substitution-to-temp-file form, `setopt`/`unsetopt` for ~200 toggleable behaviours, prompt expansion `%F{red}…%f` with no escape codes, autoload functions, and the `(L)`/`(U)`/`(s::)` parameter modifiers like `${(L)var}` for lowercase.
The portability angle: if your script is `#!/bin/sh` and uses any zsh-only construct, it dies on every non-zsh shell. The fix is either `#!/usr/bin/env zsh` plus a hard zsh dependency, or stay strictly POSIX. Inside zsh you can also drop into POSIX mode with `emulate sh` (or `emulate -L sh` to scope it to one function) — useful for running a portable subroutine from a zsh interactive session.
Syntax & semantic differences
Word-splitting on unquoted variables
sh (POSIX)POSIX splits unquoted `$var` on `$IFS` (default: space/tab/newline). `for x in $list; do …` iterates words. `[ -z $var ]` BREAKS when var is empty — always quote.
ZshZsh does NOT split unquoted scalars by default — `$var` is treated as one word even if it contains spaces. `for x in $list` iterates once, not per word. To get POSIX splitting, set `setopt SH_WORD_SPLIT` or expand as `$=var`.
The single biggest behavioural surprise porting POSIX scripts to zsh and back. A loop that worked in `/bin/sh` may run only once under zsh; conversely, a zsh-tested loop that relied on no-split may explode under sh.
Array indexing
sh (POSIX)No arrays. Positional parameters `$1 $2 ... $@` are the only sequence type.
ZshArrays start at index 1 (NOT 0 like bash). `arr=(a b c)`, `${arr[1]}` is "a", `${arr[-1]}` is "c". `${#arr}` is the length, `${arr[@]}` expands the whole array.
Zsh's 1-indexed arrays are the deepest gotcha porting bash code: `${arr[0]}` in zsh is empty (no slot 0); `${arr[1]}` is the first element. Use `KSH_ARRAYS` setopt to get bash-compatible 0-indexed behaviour.
Glob qualifiers
sh (POSIX)POSIX globs are `*`, `?`, `[abc]`. To filter by type you shell out to `find` — `find . -maxdepth 1 -type f` for files-only.
ZshZsh adds glob qualifiers — suffixes inside parens: `*(.)` = regular files, `*(/)` = directories, `*(@)` = symlinks, `*(.x)` = executable files, `*(.m-1)` = modified in the last day, `*(N)` = expand to nothing if no match. Combine: `*.txt(.mh-2)` = .txt files modified in the last 2 hours.
Recursive globbing
sh (POSIX)Not available. Use `find . -name "*.txt"` or `find . -path "*/foo/*.txt"`.
Zsh`**/*.txt` matches `.txt` in any subdirectory (zsh-native, no `globstar` opt-in needed unlike bash). Combine with qualifiers: `**/*.py(.)` = all regular `.py` files anywhere below.
Conditional tests
sh (POSIX)`[ "$a" = "$b" ]` single brackets, string `=`, numeric `-eq`. No regex.
Zsh`[[ $a == $b ]]` double brackets (also `[` for POSIX-mode tests). Supports regex `=~`, glob `==`, `&&`/`||` inside the test. Plus `[[ -o option ]]` to check a `setopt` flag.
Process substitution alternatives
sh (POSIX)Not available. Use temp files.
ZshIn addition to bash's `<(cmd)` and `>(cmd)`, zsh adds `=(cmd)` which spools the output to a real temp file (visible by name) — handy for tools that mmap or seek instead of streaming. The temp file is auto-deleted when zsh exits.
Parameter modifiers
sh (POSIX)POSIX `${var%suffix}` `${var#prefix}` `${var:-default}` `${var:?error}`. Plus the bash-style `${var:offset:length}` substring (POSIX-ish, in dash since 0.5.8).
ZshZsh adds `(L)` lowercase, `(U)` uppercase, `(C)` capitalize, `(s::)` split-on-string, `(j::)` join-with-string, `(@)` keep nulls, `(o)`/`(O)` sort/reverse-sort. Syntax: `${(L)var}` not `${var,,}`. Stackable: `${(LU)var}` = lower then upper.
Clobber-protection on redirects
sh (POSIX)POSIX `set -C` (NOCLOBBER) makes `>` fail if file exists. Override with `>|`.
ZshZsh has `setopt NO_CLOBBER` as the same toggle, but the override syntax is `>!` instead of `>|` (or zsh accepts `>|` too). `>!` is more memorable as "yes I really mean it" and is the documented form.
Prompt expansion
sh (POSIX)POSIX `PS1="\u@\h:\w$ "` with `\u` `\h` `\w` backslash escapes (bash extension actually — pure POSIX `PS1` is just literal text).
ZshZsh uses `%`-escape prompt language: `%n` user, `%m` short host, `%~` cwd with `~` substitution, `%F{red}…%f` color, `%B…%b` bold, `%(?.✓.✗)` conditional. Plus `RPROMPT` for a right-aligned prompt segment.
Function-scoped local variables
sh (POSIX)No `local` in POSIX (use `( … )` subshell trick).
Zsh`local var=val` works, and additionally `typeset` with type/scope flags. zsh's `local -A` makes an associative array local; `local -i` makes an integer (only-integer values).
Backwards-compatibility escape hatches
sh (POSIX)POSIX is the spec — no opt-in modes, what you see is what you get.
Zsh`emulate sh` switches zsh into POSIX-compatible mode for the rest of the script; `emulate -L sh` scopes the change to the current function. Useful for running portable subroutines without leaving zsh.
When debugging "works in sh, breaks in zsh" code, `emulate sh -c 'script-body-here'` is the fastest way to test under POSIX semantics without leaving the shell.
Filename-completion tab behaviour
sh (POSIX)POSIX doesn't specify interactive completion — bash, dash, busybox ash all differ. Generally bash-like.
ZshZsh's `compinit` system is famously powerful — context-aware completion, menu selection, prefix-matching for partial words, descriptions next to each candidate. Per-command completion specs live in `/usr/share/zsh/site-functions/_*`.
Gotchas when porting between them
- **`#!/bin/sh` runs under whatever `/bin/sh` is, NOT zsh** — even on macOS where zsh is the default interactive shell, `/bin/sh` is `bash --posix` (or `dash` on Linux). Scripts that use zsh-only features need `#!/usr/bin/env zsh` or `#!/bin/zsh`; otherwise glob qualifiers, `**/*`, and array 1-indexing all error or behave differently.
- **Zsh arrays are 1-indexed** (`${arr[1]}` is the first element, `${arr[0]}` is empty). This breaks every bash port that does `${arr[0]}` for "first element". Fix per script: `setopt KSH_ARRAYS` switches to 0-indexed plus bash-compatible `${arr[@]:offset:length}` semantics. Scope it: `emulate -L bash` inside a function.
- **No automatic word-splitting on unquoted scalars** is the silent killer porting POSIX one-liners. `pids=$(pgrep firefox); kill $pids` works in bash/sh (splits on whitespace, kills each PID); in zsh it sends one space-separated argument to kill. Fix: `kill ${=pids}` (the `=` prefix forces splitting) or `pids=( $(pgrep firefox) ); kill $pids[@]`.
- **Glob qualifiers can hijack legitimate `(…)`** — a script that does `for f in *.txt(.bak); do` (intending `.txt.bak` files) instead asks zsh for `.txt` files matching glob qualifier `.bak`, which is a syntax error. Always quote glob patterns when they look like they have qualifiers: `for f in "*.txt(.bak)"; do` or escape: `*.txt\(.bak\)`.
- **`setopt` state persists across `source`d files** — sourcing a script that runs `setopt KSH_ARRAYS` leaves your interactive shell in KSH_ARRAYS mode after the script returns. Best practice: scripts use `emulate -L` (the `-L` scopes setopts to the function/file) rather than raw `setopt`. Or wrap in `(setopt KSH_ARRAYS; …)` subshell.
Full references
- sh (POSIX)POSIX-strict shell — the lowest common denominator for portable scripts. No arrays, no `[[ ]]`, no process substitution.
- Zsh referenceDefault macOS shell since Catalina. Mostly bash-compatible with nicer globbing and prompts.