Ray's Knowledge Base

zsh does not split an unquoted variable into words, unlike bash

PitfallVerified 28 Sep 2026Holds anywhere
Pitfall. The symptom, what causes it, and the fix that was run and seen to work.

Symptom#

A test script stores several arguments in one variable and passes it unquoted, as in bash:

heads="a b c"
git merge $heads

In zsh the command gets one argument, a b c:

merge: a b c - not something we can merge

The same happens in a loop over whole command lines:

for c in "pdf info a.pdf" "table info b.xlsx"; do rait $c --format text; done

Each run gets one argument, pdf info a.pdf, and fails:

message: unrecognized subcommand 'pdf info a.pdf'

Claude Code's Bash tool runs commands in the user's login shell, which was zsh here, so a bash habit silently tests the wrong thing.

Cause#

zsh does not do word splitting on unquoted parameter expansion by default (option SH_WORD_SPLIT is off). bash and POSIX sh split on $IFS.

Fix#

Pass the arguments explicitly (git merge a b c), use an array (heads=(a b c); git merge "${heads[@]}", works in bash and zsh), or in zsh only use ${=heads}. For scripts that must behave like bash, run them with bash -c '...' or a #!/bin/bash file.

To run a list of command lines, drive them from Python with argument lists, which no shell splits:

import subprocess
for c in ["pdf info a.pdf", "table info b.xlsx"]:
    subprocess.run(["rait", *c.split(), "--format", "text"])

Evidence#

rgit, 2026-09-28: a comparison script for octopus merges ran git merge $heads and rgit merge $heads in the Bash tool. Both received one argument and failed with the message above, so the first comparison said nothing about octopus merges. With merge a b c written out, both tools ran the real merges.

rait, 2026-09-27: three shell loops over command strings (for c in ...; $R $c) in one session all sent the whole string as one argument and failed with unrecognized subcommand. A Python driver with subprocess.run([R, *c.split()]) ran every command correctly.