Moving to zsh, part 8 – Scripting zsh

Apple has announced that in macOS 10.15 Catalina the default shell will be zsh.

In this series, I will document my experiences moving bash settings, configurations, and scripts over to zsh.

This series has grown into a book: reworked and expanded with more detail and topics. Like my other books, I plan to update and add to it after release as well, keeping it relevant and useful. You can order it on the Apple Books Store now.

This is the final article in this series. (If I ever announce an eight part series again, please somebody intervene!) However, I am quite sure it will not be the last post on zsh

All the previous posts described how zsh works as an interactive shell. The interactive shell is of course the most direct way we use a shell and configuring the shell to your taste can bring a huge boost in usefulness and productivity.

The other, equally, important aspect of a shell is running script files. In the simplest perspective, script files are just series of interactive commands, but of course they will get complex very quickly.

sh, bash, or zsh?

Should you even script in zsh? The argument for bash has been that it has been pre-installed on every Mac OS X since 10.2. The same is true for zsh, with one exception: the zsh binary is not present on the Recovery system. It is also not present on a NetInstall or External Installation System, but these are less relevant in a modern deployment workflow, which has to work with Secure Boot Macs.

If you plan to run a script from Recovery, such as an installr or bootstrappr script or as part of an MDS workflow, your only choices are /bin/sh and /bin/bash. The current /bin/bash binary is 12 years old, and Apple is messaging its demise. I would not consider that a future proof choice. So, if your script may run in a Recovery context, i would recommend /bin/sh over either /bin/bash or /bin/zsh

Since installation packages can be run from the Recovery context as well, and you cannot really always predict in which context your package will be used, I would extend the recommendation to use /bin/sh for all installation scripts as well.

While sh is surely ubiquitous, it is also a ‘lowest common denominator’, so it is not a very comfortable scripting language to work in. I recommend using shellcheck to verify all your sh scripts for bashisms that might have crept in out of habit.

When you can ensure your script will only run on a full macOS installation, zsh is good choice over sh. It is pre-installed on macOS, and it offers better and safer language options than sh and some advantages over bash, too. Deployment scripts, scripts pushed from management systems, launch daemons and launch agents, and script you write to automate your admin workflows (such as building packages) would fall in this category.

You can also choose to stick with bash, but then you should start installing and using your own bash 5 binary instead of the built in /bin/bash. This will give you newer security updates and features and good feeling that when Apple does eventually yank the /bin/bash binary, your scripts will keep working.

Admins who want to keep using Python for their scripts are facing a similar problem. Once you choose to use a non-system version of bash (or python), it is your responsibility to install and update it on all your clients. But that is what system management tools are for. We will have to get used to managing our own tools as well as the users’ tools, instead of relying on Apple.


To switch your script from using bash to zsh, you have to change the shebang in the first line from #!/bin/bash to #!/bin/zsh.

If you want to distinguish your zsh script files, the you can also change the script’s file extension from .sh to .zsh. This will be especially helpful while you transfer scripts from bash to zsh (or sh). The file extension will have no effect on which interpreter will be used to run the script. That is determined by the shebang, but the extension provides a visible clue in the Finder and Terminal.

zsh vs bash

Since zsh derives from the same Bourne shell family as bash does, most commands, syntax, and control structures will work just the same. zsh provides alternative syntax for some of the structures.

zsh has several options to control compatibility, not only for bash, but for other shells as well. We have already seen that options can be used to enable features specific for zsh. These options can significantly change how zsh interprets your scripts.

Because you can never quite anticipate in which environment your particular zsh will be launched in, it is good practice to reset the options at the beginning of your script with the emulate command:

emulate -LR zsh

After the emulate command, you can explicitly set the shell options your script requires.

The emulate command also provides a bash emulation:

emulate -LR bash

This will change the zsh options to closely emulate bash behavior. Rather than relying on this emulation mode, I would recommend actually using bash, even if you have to install and manage a newer version yourself.

Word Splitting in Variable Substitutions

Nearly all syntax from bash scripts will ‘just work’ in zsh as well. There are just a few important differences you have to be aware of.

The most significant difference, which will affect most scripts is how zsh treats word splitting in variable substitutions.

Recap: bash behavior

In bash substituted variables are split on whitespace when the substitution is not quoted. To demonstrate, we will use a function that counts the number of arguments passed into it. This way we can see whether a variable was split or not:

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

function countArguments() {
    echo "${#@}"

wordlist="one two three four five"

echo "normal substitution, no quotes:"
countArguments $wordlist
# -> 5

echo "substitution with quotes"
countArguments "$wordlist"
# -> 1

In bash and sh the contents of the variable split into separate arguments when substituted without the quotes. Usually you do not want the splitting to occur. Hence the rule: “always quote variable substitutions!”

zsh behavior: no splitting

zsh will not split a variable when substituted. With zsh the contents of a variable will be kept in one piece:

emulate -LR zsh # reset zsh options
export PATH=/usr/bin:/bin:/usr/sbin:/sbin

function countArguments() {
    echo "${#@}"

wordlist="one two three four five"

echo "normal substitution, no quotes:"
countArguments $wordlist
# -> 1

echo "substitution with quotes"
countArguments "$wordlist"
# -> 1

The positive effect of this is that you do not have to worry about quoting variables all the time, making zsh less error prone, and much more like other scripting and programming languages.

Splitting Arrays

The wordlist variable in our example above is a string. Because of this it returns a count of 1, since there is only one element, the string itself.

If you want to loop through multiple elements of a list

In bash this happens, whether you want to or not, unless you explicitly tell bash not to split by quoting the variable.

In zsh, you have to explicitly tell the shell to split a string into its components. If you do this naïvely, by wrapping the string variable in the parenthesis to declare and array, it will not work:

wordlist="one two three"
wordarray=( $wordlist )

for word in $wordarray; do
    echo "->$word<-"

->one two three<-

Note: the for loop echoes every item in the array. I have added the -> characters to make the individual items more visible. In the subsequent examples, I will not repeat the for loop, but only show its output. So the above example will be shortened to:

wordarray=( $wordlist )
->one two three<-

There are few options to do this right.

Revert to sh behavior

First, you can tell zsh to revert to the bash or sh behavior and split on any whitespace. You can do this by pre-fixing the variable substitution with an =:

wordarray=( ${=wordlist} )

Note: if you find yourself using the = frequently, you can also re-enable sh style word splitting with the shwordsplit option. This will of course affect all substitutions in the script until you disable the option again.

setopt shwordsplit
wordarray=( $wordlist )

This option can be very useful when you quickly need to convert a bash script to a zsh script. But you will also re-enable all the problems you had with unintentional word splitting.

Splitting Lines

If you want to be more specific and split on particular characters, zsh has a special substitution syntax for that:

macOSversion=$(sw_vers -productBuild) # 10.14.6

If you want to split on a newline character \n the syntax is slightly different:

citytext="New York

cityarray=( ${(ps/\n/)citytext} )
->New York<-

Since newline is a common character to split text on, there is a short cut:

cityarray=( ${(f)citytext} )

Since the newline character is a legal character in file names, you should use zero-terminated strings where possible:

foundDirs=$(find /Library -type d -maxdepth 1 -print0)

Again, there is a shortcut for this:


Array index starts at 1

Once you have split text into an array, remember, that in zsh array indices start at 1:

% versionList=( ${(s/./)$(sw_vers -productVersion)} )
% echo ${versionList[1]}
% echo ${versionList[2]}
% echo ${versionList[3]}

If you think this is wrong and absolutely require a zero-based index, you can set the KSH_ARRAYS shell option:

% setopt KSH_ARRAYS
% echo ${versionList[0]}
% echo ${versionList[1]}
% echo ${versionList[2]}
% echo ${versionList[3]}


Switching your scripts from bash to zsh requires a bit more work than merely switching out the shebang. However, since /bin/bash will still be present in Catalina, you do not have to move all scripts immediately.

Moving to sh instead of zsh can be safer choice, especially for package installation scripts.

In zsh, there always seems to be some option to disable or enable a particular behavior.

This concludes my series on switching to zsh on macOS. I hope you found it helpful.

After having worked with zsh for a few weeks, I already find some of its features indispensable. I am looking forward to discovering and using more features over time. When I do, I will certainly share them here.

Published by


Mac Admin, Consultant, and Author

7 thoughts on “Moving to zsh, part 8 – Scripting zsh”

  1. I had a few plist files in ~/Library/LaunchAgents that loaded and executed just fine synchronizing to my git repository around 3 am.
    After updating to Catalina and zsh they do not run anymore.

    I can start the scripts by hand in zsh. What is wrong? Any ideas?

    1. This has nothing to do with zsh. Your launch agents are running into the new, extended Privacy Controls in Catalina which restrict the access of processes to files in your Documents, Desktop, and Downloads folders

      1. I had a similar problem. I had to add zsh to full disk access and change #!/bin/sh to #!/bin/zsh to get the script to run.

      2. Giving full disk access to zsh rather than Terminal can be a security problem. Now _all_ zsh scripts and process have full disk access.

  2. I’m a student, a couple months in. I just read this line by line, beginning to end. This is a one-stop shop and a real education. I am so glad I took the time to understand this without a sweeping zsh plugin. Appreciate the pacing of your prose. I’ve gotta get your book. Thank you sir.

  3. Error (forgotten surrounding “( )”) in 2 code sections make code not work as described:
    1: “foundDirs=$(find /Library -type d -maxdepth 1 -print0)
    dirlist=${(ps/\0/)foundDirs}” should be correctly
    “foundDirs=$(find /Library -type d -maxdepth 1 -print0)
    dirlist=( ${(ps/\0/)foundDirs} )”;
    2: Accordingly, the immediately following short form “dirlist=${(0)foundDirs}” should be correctly “dirlist=( ${(0)foundDirs} )”

  4. Little annoying correction: You can *not* safely omit quotes. If the variable contains the empty string it will be interpreted as “nothing” rather than “empty string”, which is the same as bash with IFS=””.

    (zsh)> y=””
    (zsh)> realpath “$y”
    realpath: ”: No such file or directory
    (zsh)> realpath $y
    realpath: missing operand

    With this little edge-case, we’re basically back to all the same issues as with bash. Only, it is even more inconsistent:

    • BASH: $y expands as “IFS separated array”. Splitting and interpreting “” as empty array is consistent with this.
    • ZSH: $y expands as one string, if $y is non-empty, and as zero strings, if $y is empty. I can’t come up with a way to interpret this as consistent behavior.

    It is probably less likely to occur though than having spaces.

Comments are closed.