# Shell - Zsh and Bash Differences Zsh and bash differ silently in variable semantics, word splitting, and expansion. A script that works in one shell breaks in the other. Portability-critical shell code must avoid shell-specific constructs, or tests must run under both shells. ## Key points - In bash, `${2:?message}` in a function exits the entire non-interactive script when the argument is unset or null. A function that must return to its caller needs an explicit `return 1` instead of relying on the modifier. - In zsh, re-declaring a variable with `local` or `typeset` inside a `for` loop prints the current value as output on each iteration. Declare loop variables once at the top of the function. - `$status` is a read-only special variable in zsh, because it mirrors `$?`. Assignment to it fails, even under `local`. In bash the same name only shadows. Use a different name, such as `rc`. - `cksum <(cmd)` through process substitution strips the trailing newline. A bare pipe keeps it. Identical content then produces different checksums. Capture both sides of the comparison through the same path to normalize. - In zsh, `$SHA:t` parses `:t` as a history modifier. Brace-quote the expansion (`${SHA}`) when a colon follows a variable name. - In zsh, `for n in $NUMS` does not split on newlines. The loop runs once with the whole input as one value. `while read -r` behaves the same in both shells. - `"${1:-$PWD}"` substitutes `$PWD` when the first argument is unset. This expansion is valid in both shells, so it keeps functions ergonomic without a `cd` first. ## How to apply 1. Prefer POSIX-agnostic constructs in shared scripts: `while read -r` for line iteration, `rc` for status variables, `${VAR}` when a colon follows the variable name. 2. Validate required arguments and end the failure path with an explicit `return 1`, so the function works under both shells. 3. Declare all loop variables with one `local` statement at the top of a zsh function. 4. Compare content that passes through pipes and process substitution through the same path, so trailing-newline differences cancel out. 5. Give optional path arguments the form `"${1:-$PWD}"`. ## Observations - [shell] `${2:?message}` exits a non-interactive bash script from inside a function. Use `return 1` when the caller must keep control. #shell #bash (source: agent sessions, 2026-03) - [zsh] Re-declaring a loop variable with `local` or `typeset` on each iteration leaks the value as output. #zsh (source: agent sessions, 2026-03) - [shell] `status` is read-only in zsh and mirrors `$?`. Assigning it fails even under `local`. Use `rc`. #zsh #portability (source: agent sessions, 2026-03) - [shell] Process substitution strips a trailing newline for `cksum`, while a bare pipe keeps it, so identical content hashes differently. Capture both sides through the same path. #shell #portability (source: agent sessions, 2026-03) - [shell] `status` fails on assignment in zsh, but bash only shadows it. The safe name is `rc` in both shells. #zsh #portability (source: agent sessions, 2026-05) - [shell] `"${1:-$PWD}"` gives a shell function a default directory argument without a prior `cd`. #shell (source: agent sessions, 2026-05) - [zsh] A colon after a variable name parses as a history modifier. Brace-quote the expansion, as in `${SHA}`. #zsh (source: agent sessions, 2026-06) - [zsh] `for n in $NUMS` runs once with the whole input, because zsh does not split on newlines. `while read -r` is shell-agnostic. #zsh #portability (source: agent sessions, 2026-07) ## Relations - relates_to [[resources/documentation/Zsh Configuration]] (zsh settings interact with these shell-specific behaviors) - relates_to [[resources/documentation/Shell - BSD and GNU Tools on macOS]] (the same portability theme across tool families) - relates_to [[resources/documentation/Shell - Exit Status, Traps, and Signals]] (exit-status handling connects to the read-only `$status` variable) ## Sources - Personal agent-session notes, March 2026 to July 2026