# 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