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
.
- Part 1: Moving to
zsh
- Part 2: Configuration Files
- Part 3: Shell Options
- Part 4: Aliases and Functions
- Part 5: Completions
- Part 6: Customizing the
zsh
Prompt - Part 7: Miscellanea
- Part 8: Scripting
zsh
(this article)
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 bash
isms 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.
Shebang
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:
#!/bin/bash
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:
#!/bin/zsh
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<-"
done
#output
->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 thefor
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} )
->one<-
->two<-
->three<-
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 )
->one<-
->two<-
->three<-
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
versionParts=${(s/./)macOSVersion}
->10<-
->14<-
->6<-
If you want to split on a newline character \n
the syntax is slightly different:
citytext="New York
Rio
Tokyo"
cityarray=( ${(ps/\n/)citytext} )
->New York<-
->Rio<-
->Tokyo<-
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)
dirlist=${(ps/\0/)foundDirs}
Again, there is a shortcut for this:
dirlist=${(0)foundDirs}
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]}
10
% echo ${versionList[2]}
14
% echo ${versionList[3]}
6
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]}
10
% echo ${versionList[1]}
14
% echo ${versionList[2]}
6
% echo ${versionList[3]}
Conclusion
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.
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?
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
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.
Giving full disk access to zsh rather than Terminal can be a security problem. Now _all_ zsh scripts and process have full disk access.
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.
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} )”
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.