Zsh Gotchas — Four Expansions That Rewrite Your Command
Four zsh behaviours with no bash equivalent. Each one fires on ordinary
commands, including the one-liners an agent runs through the Bash tool — not
just on .zsh files — so a snippet lifted from a bash-tested doc breaks only
here.
What makes them expensive is that three of the four fail silently or misattribute: the command runs, exits, and reports something that looks like a real answer about the world. Every one below was diagnosed the wrong way first.
| Symptom | Mechanism | Section |
|---|---|---|
A URL/path with $var: hits the wrong endpoint; fast empty 404 | $VAR:x is a history modifier | 1 |
A later command in a ; chain produced no output | a word starting with = is a command lookup | 2 |
$2 empty after set -- $spec; tool prints its usage text | zsh does not word-split | 3 |
command not found for a tool that is installed | path is tied to PATH | 4 |
Out of scope: zsh-vs-POSIX pattern expansion and extended glob. Those bite when
writing .zsh / zshrc files, not on every command, and are not covered here.
When to Use This Skill
| Use this skill when... | Use shell-expert instead when... |
|---|---|
| A zsh command ran and returned a result that does not match what you typed | Writing a shell script, function, or pipeline from scratch |
| An unexpected 404, an empty positional, or a missing chunk of output from a chain | Choosing portable constructs across bash / zsh / POSIX |
command not found names a tool you know is installed | Structuring error handling, set flags, or CLI argument parsing |
| A bash-tested snippet behaves differently once run through the Bash tool | The question is shell style rather than a wrong runtime result |
The split is runtime versus authoring: this skill covers four expansions that make a correct-looking zsh command do something else, three of them silently.
1. $VAR:word Is a Modifier, Not a Colon After a Variable
Zsh applies history-style modifiers directly to a bare parameter expansion:
$f:h (dirname), $f:t (basename), $f:r, $f:e, $f:s/a/b/, $f:g…. So a
URL that puts a variable straight before a colon is not the URL you wrote:
# Wrong — `:g` is read as the start of a global-substitution modifier
curl "https://…/v1beta/models/$MODEL:generateContent"
Observed 2026-09-05 (robocar-unified, probing a replacement Gemini model): three
requests came back HTTP 404 in 60 ms with an empty body, and were nearly
reported as "the new model does not support generateContent". The empty body and
the sub-100 ms turnaround were the tell — a real API 404 carries a JSON error and
takes a round trip. The same request with braces returned 200.
The fix
Brace the expansion. Braces end the parameter name, so the colon is literal:
curl "https://…/v1beta/models/${MODEL}:generateContent"
Double quotes do not protect you — modifiers apply inside them.
When it bites
- Gemini-style
model:methodREST paths,host:port,user:group,file:line,scp/rsynchost:pathtargets — any$var:with a letter after the colon. - A modifier letter that zsh does not recognise errors loudly
(
unrecognized modifier); the recognised ones (g,h,t,r,e,s,a,A,l,u,q,Q,x,c,P) fail silently by rewriting the string.
2. A Word Starting With = Is a Command Lookup
With EQUALS set (the zsh default), an unquoted word that begins with = is
replaced by the path of the command named after it: =ls becomes /bin/ls.
When no such command exists, the expansion is a fatal error that aborts the
rest of the command line, not just that word, and ; does not contain it:
zsh -fc 'echo a; echo ======; echo b'
# a
# zsh:1: ===== not found ← `echo b` never runs, exit 1
Observed 2026-09-15 (claude-plugins, session-end): survey.sh …; echo ======; distill-survey.sh … printed the survey, then (eval):1: ===== not found, and
the second collector silently never ran. The error names the word minus its
leading =, which reads like a missing command rather than a separator.
The fix
Quote the word (echo '======'), or use a separator that does not start with
= (echo ---). setopt noequals also works but changes the shell for
everything after it.
When it bites
- Section separators in chained diagnostics (
echo =====,print ==== x). - Arguments that start with
=:--flag =value,git log =main, a jq or awk program passed unquoted. - Mid-chain placement: everything after the bad word is lost, so the symptom is missing output from a later command, not an error at the one you wrote.
3. Zsh Does Not Word-Split an Unquoted Parameter
Bash splits an unquoted $var on IFS; zsh does not, because SH_WORD_SPLIT
is off by default. The whole string stays one word, so $1 is everything and
$2 is empty:
# Wrong — $2 is empty, so gh runs without a PR number
for spec in "ForumViriumHelsinki/infrastructure 2379" "ForumViriumHelsinki/.github 127"; do
set -- $spec
gh pr view "$2" -R "$1" --json state
done
Observed 2026-09-16 (verifying three PRs after a merge): the loop printed gh's
usage text three times — "argument required when using the --repo flag" — and
verified nothing. The message names a flag, so it reads as a wrong invocation
rather than an empty variable, and the obvious next move is to rewrite the gh
call that was already correct.
The fix
Force splitting with ${=var}, or read into named variables:
for spec in "ForumViriumHelsinki/infrastructure 2379"; do read -r repo num <<< "$spec"; gh pr view "$num" -R "$repo" --json state; done
Named variables are the better habit: they survive a copy into a bash script, and they say what each field is.
When it bites
set -- $line/set -- $specover a list of space-separated records — the natural way to unpack "repo number" or "host port" pairs.cmd $argswhereargsholds several flags. Zsh passes them as one argument; the tool reports an unknown option containing spaces.- Splitting on something other than whitespace:
${(s:,:)csv}in zsh, notIFS=, read.
The failure looks like a clean result, not an error
Both halves of that session's evidence were silent. The gh loop printed usage
text, and a malformed taskwarrior query minutes later (task export status:pending — taskwarrior wants the filter before the command) returned
three empty lists that read exactly like an empty queue. A control re-run with
the correct order returned 457 tasks. Control-test any negative that gates an
action.
4. path Is Not a Free Variable Name
Zsh ties path to PATH: path is the array view, PATH the scalar view,
and writing either rewrites the other. Assigning a string to path therefore
destroys the search path for the rest of the shell.
The usual way in is a loop variable chosen for readability:
# Wrong — `path` is special; PATH is destroyed on the FIRST iteration
while IFS=$'\t' read -r key title labels path; do
gh issue create --title "$title" --body-file "$path" ...
done < list.tsv
Observed 2026-09-02 (silverbucket-helper, filing eight issues): every iteration
printed command not found: tail, and nothing was created — gh was already
unreachable by the time the body ran.
Zsh's tied variables are a small set, and the rest are just as ordinary looking:
path, cdpath, fpath, manpath, fignore, mailpath, module_path,
prompt, psvar, status, argv. path and fpath are the two a script is
most likely to reach for by accident.
The fix
Pick a name that is not tied. Any of bodyfile, file, p, target works; the
rename is the whole fix.
while IFS=$'\t' read -r key title labels bodyfile; do gh issue create --title "$title" --body-file "$bodyfile"; done < list.tsv
typeset does not rescue you — local path inside a function still shadows the
tied parameter and still breaks command lookup for that function's body.
Check whether anything actually ran
The failure is loud but misattributed: zsh reports the missing command, not the cause, so the obvious next move is to fix the "missing" tool. Before retrying a loop that had side effects, establish whether the side effects happened — a partially-completed run retried from the top creates duplicates.
gh issue list --state open --limit 30 --json number,createdAt
In the case above the answer was none, because PATH died on iteration one
before gh was reached. Had it died later, some issues would exist and a blind
retry would have double-filed them.
Verifying a Suspected Hit
All four are testable in one line against a pristine shell (zsh -f skips
rc files, so the result is the language's behaviour and not this machine's):
zsh -fc 'M=models/x; echo "$M:generateContent"; echo "${M}:generateContent"'
zsh -fc 'v="a b"; set -- $v; echo "bare: 1=[$1] 2=[$2]"; set -- ${=v}; echo "split: 1=[$1] 2=[$2]"'
If the braced or ${=…} form differs from the bare one, the expansion is the
bug — not the tool you were calling.