Bash / zsh / sed pitfalls. Each entry follows symptom → why → how to apply.
Symptom: Scripts that work on Linux (GNU sed) break on macOS and vice versa —
a recurring class of trouble. Worse, on machines with Homebrew GNU sed (gsed)
installed, which sed runs becomes environment-dependent, so "it worked on my
machine" says nothing about portability.
Why (the major incompatibilities):
-i(in-place) syntax differs. BSD sed requires an extension argument (sed -i '' -e 's/a/b/' f); GNU sed takes bare-i.sed -i '' ...breaks GNU (treated as an empty filename); bare-imakes BSD sed eat the next argument as the extension.- GNU extensions (BRE
\+\|, thes///Iflag,sed -z, …) don't exist in BSD sed. Line-continuation fora/i/calso differs (BSD needsa\+ newline). - Aliases are interactive-only:
alias sed=gsedin interactive zsh does not affectsedinside scripts, which resolve via PATH. Conversely, putting Homebrew's gnubin ($(brew --prefix)/opt/gnu-sed/libexec/gnubin) on PATH switches scripts to GNU sed too, diverging from stock macOS. This structure is why one-liners that worked interactively break in scripts, and vice versa.
How to apply:
- Safest in scripts: avoid
-ientirely — standardize onsed ... > tmp && mv tmp file(identical behavior on containers/Linux and macOS). If-imust be used, restrict to the attached form-i.bak(accepted by both) and delete the.bakafterwards. - When GNU features are needed, don't rely on whatever
sedis: callgsedexplicitly, check withcommand -v gsed, and fail with a clear message when missing. - Runtime detection:
sed --versionanswers only for GNU (BSD sed errors). - Review trigger: on seeing
sed -i, always ask "on which OS, with which sed?"
Symptom: Pasting bash-oriented one-liners and snippets into macOS's default interactive shell (zsh) changes their behavior — another recurring class of trouble.
Why (the major differences):
- Unquoted
$varis not word-split (SH_WORD_SPLIToff by default):opts="-l -a"; ls $optssplits into two args in bash but passes the single word"-l -a"in zsh. - Unquoted
$varis not glob-expanded (GLOB_SUBSToff by default):pat='*.md'; ls $patpasses the literal*.mdin zsh. - Unmatched globs are errors (
NOMATCHon by default): bash passes the pattern through literally; zsh aborts withzsh: no matches foundbefore running the command.scp host:*.gz .failing on the local glob is the classic case. - Arrays are 1-indexed, and
$arrayexpands to all elements (bash: first element only).
How to apply:
- Give scripts a
#!/bin/bash(or#!/usr/bin/env bash) shebang and run them as bash, independent of the interactive shell's dialect. Avoidsh file.shandsource-ing (both change the interpreter). - When targeting zsh, use the explicit operators:
${=var}for word splitting,${~var}for globbing,*.tmp(N)(orsetopt null_glob) to tolerate no matches. Hold argument lists as arrays, not strings (opts=(-l -a); ls $opts). - Always quote globs meant to expand remotely (scp/ssh).
- One-liners in documentation either state "run under bash" or are written to behave identically in both shells.
Symptom: A cleanup trap handler referenced local variables and died with
unbound-variable errors under set -u (the same trap was hit three times).
How to apply:
- Declare every variable a trap handler touches as global.
- Under
set -e, a non-zero command before the trap kills the script — protect with|| true. - Trap
INT TERM, not justEXIT.
Symptom: A substitution loop resolving $(VAR) references silently did
nothing. No error, so the cause stayed invisible until a test was written.
s='$(OUT)'; val='dist/tool'
echo "${s//\$(OUT)/$val}" # => $(OUT) not replaced (no error either)
pat='$(OUT)'; echo "${s//"$pat"/$val}" # => dist/tool correctHow to apply: For placeholder substitution in bash, put the pattern in a
variable and write ${s//"$pat"/$rep}. Suspect any existing inline form. Never
"simplify" it back inline (leave a prohibition comment in the code).