Skip to content
shellmap

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.

    Zsh

    Zsh 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.

    Zsh

    Arrays 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.

    Zsh

    Zsh 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.

    Zsh

    In 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).

    Zsh

    Zsh 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 `>|`.

    Zsh

    Zsh 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).

    Zsh

    Zsh 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.

    Zsh

    Zsh'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

Other comparisons