Shells like Bash or Zsh are advanced and user-friendly, and include features beyond what a simpler POSIX-compliant shell might offer. You will do well to utilize the full features of your shell when writing scripts.
There are situations, however, when portability should be a valued feature, allowing the script to run on a variety of shells.
Bash scripts are most portable when "bashisms" are avoided. Let's explore writing POSIX-compliant shell scripts that work on Ash/Dash and other shells.
A summary checklist
Here is a checklist I use to keep tabs on my own script writing. Does my script:
- Have
#!/bin/sh
as the first ("shebang") line of the script, not#!/usr/bin/bash
or other shell - Avoid double-bracket tests
[[ ]]
and instead use single-brackets[ ]
- Use
printf
instead ofecho -e
when newlines'\n'
need to be printed - Use no other
read
flag other than-r
, as inread -r
- Avoid Bash's convenience redirects: use
>myfile 2>&1
to redirect stdout and stderr to a file rather than&>myfile
- Test accurately with dash or posh: Policy-compliant Ordinary SHell
- Only use standard flags and options with common utilities such as sed, grep, cut, test, and others
- Avoid issues discovered by shellcheck
An example
#!/bin/sh
read -p "Who would you like to greet? "
if [[ -z $REPLY ]] ; then
recipient="$REPLY"
else
recipient="World"
fi
echo -e "Hello\n$recipient\n"
You might save the following in the current working directory of your choice, as example-noncompliant.sh
In human language, the above script prompts for a greeting recipient, then sets the recipient to "World" if none was given, then greets the recipient on multiple lines.
The above works on Bash, but has issues on other shells. You may wish to run it once in Bash, just to feel good. Even in Zsh, though, it may raise some complaints.
Dash, a POSIX compliant shell
Dash is a derivative of the Ash shell. It is meant to be POSIX-compliant.
Debian and Ubuntu come with dash installed. In fact, scripts invoked with /bin/sh
will run with dash by default. On Alpine, Tiny Core Linux, OpenWRT, and other distros that use BusyBox by default, the standard shell is also dash (although labeled as ash
). On Fedora, dash can be installed with sudo dnf install dash
. Other distros may also include dash in their repositories.
Using Docker or Podman, running dash is easy, as in this example:
docker run -it debian dash
You may also try my POSIX playground container, with a variety of tools, including dash. By default, it uses posh: Policy-compliant Ordinary SHell, which is slightly stricter than dash. It can be launched with:
docker run -it docker.io/bowmanjd/posix-playground
See the article for a deeper explanation.
In all of the above, podman
can replace docker
without a problem.
Testing the example
Can you try running the example-noncompliant.sh
script above, but with dash, not Bash?
dash example-noncompliant.sh
Or, using Docker or Podman:
docker run -it -v "$(pwd):/work" debian dash /work/example-noncompliant.sh
The output is likely something resembling:
dash: 3: read: arg count
dash: 5: [[: not found
-e Hello
A few learning points can be derived from that output.
Avoid double-bracket tests [[ ]]
The [[
construct is a safe one if using Bash or another shell that supports bashisms. It has some convenient features, like regex matching using =~
, and has less risks with string matching.
That said, you will generally not go wrong with the single bracket approach: [ ]
(an alias for test
). Always be sure to quote variables, but that is good advice anyway. If you need regular express matching, use grep
.
Bottom line: not all shells support [[
; use [
instead.
Use vanilla read
When using the read
command to get input, here are a few suggestions:
- Always specify the variable, rather than relying on Bash's default
$REPLY
- Use
read -r
and no other flags. Using-r
prohibits the user from using backslash to escape characters, which can cause issues later. And no other flag is supported by POSIXread
. - Instead of specifying a prompt with
-p
, just use aprintf
call prior to theread
command. Again, POSIXread
does not support such a flag, plus the-p
option means something different to Zsh'sread
.
Given these rules, our script should not use read -p "Who would you like to greet? "
but rather:
printf "Who would you like to greet? "
read -r recipient
Use printf
when newlines are at issue
The echo
command works great when we know we want to output a simple string, followed by a newline.
However, if we have newlines in a string we want to print, or if printing without a trailing newline is desired, then printf
, not echo -e
will be our friend.
So, instead of echo -e "Hello\n$recipient\n"
in our code above, this would be better:
printf "Hello\n%s\n\n" "$recipient"
Note the variable substitution going on with %s
in the first string (the format string). Do not put shell variables like $recipient
in the format string. This is the way.
Refactoring the example
Given the above concerns, let's completely rewrite our greeting script:
#!/bin/sh
printf "Who would you like to greet? "
read -r recipient
if [ -z "$recipient" ] ; then
recipient="World"
fi
printf "Hello\n%s\n\n" "$recipient"
You might save the above with the filename example-posix.sh
or similar.
When you run it, does it behave the same as the noncompliant script? Hopefully not; try it out.
Satisfying.
Finding the bashisms with checkbashisms
There is a tool embedded in the Debian devscripts project, called checkbashisms. It is a simple but powerful Perl script that ferrets out any bashisms in a shell script that begins with the #!/bin/sh
shebang line.
On Debian and Ubuntu, it can be installed with sudo apt install devscripts
and on Fedora with sudo dnf install devscripts-checkbashisms
while Alpine is sudo apk add checkbashisms
. Other distros may have something similar. You might also try installing Perl, then downloading and running the checkbashisms Perl script itself.
What happens when you run it on example-noncompliant.sh
or example-posix.sh
? So telling...
Pursuing best practices with shellcheck
My new favorite shell scripting helper is Shellcheck. You can paste your shell script online and check it there, or install shellcheck in the usual way (Debian, Ubuntu, Fedora, Alpine, Archlinux, and others have it readily available in the standard package repositories.)
It does raise the POSIX-compliance flag on any lines that need it, but many other issues are checked as well. Your code might run just fine, but have gotchas that need some attention. Shellcheck will help you there. I integrate it into my editor, so that I can lint while I type.
Measure against the POSIX specification
Thankfully, the POSIX.1-2017 standard is openly documented. Consider this: when discovering and testing options for a given tool like read
, grep
, or sed
, instead of going to the GNU pages, the distro man pages, or the Bash or Zsh docs, why not go to the POSIX spec itself? The list of utilities and their options is plainly explained.
In instances where you really need an enhancement provided by the extended tools, you can make that choice. With the POSIX spec in hand, it becomes an informed decision.
Often, I find that I don't need sed -E
or grep -E
as badly as I thought. A few extra escape characters, and I am there.
Consider other languages and configuration tools
Sometimes, when the extended syntax provided by GNU utilities is warranted, it may be a sign that the right tool for the job isn't the shell and its compatriots at all.
If your system has Python, Ruby, NodeJS, or other favorite language, might that be a more robust, flexible, and consistent option? Even in circumstances (embedded systems) in which those runtimes would be too bulky, perhaps remote scripting from another machine is in order. For instance, one could use Python to SSH to the remote machine, gain the information necessary, perform some logic, then send the appropriate commands back, without Python being necessary on the target machine.
This is the reason such tools as Ansible, Saltstack, Chef, and Puppet exist. These, too, can be quite bloated if the needs are simple. But they are unbeatable for flexibility and repeatability.
Other resources
In my research for this article, I encountered some resources you may find at least as interesting as this one:
- The informative and well-written A Brief POSIX Advocacy: Shell Script Portability by Arnaud Tomeï
- The Autoconf portable shell guide
- Sven Mascheck's remarks on various Unix tools
- How to make bash scripts work in dash by Greg Wooledge
Please feel free to share your tips, questions, corrections in the comments!
Top comments (1)
Hey, @bowmanjd , great blog. 👏
Do you know about DeepSource's Shell analyzer?
It detects issues in
sh
,bash
,dash
andksh
and lets you automatically fix most of them. It covers ShellCheck issues as well.It's free for Open-Source projects.
Do give it a try and share your thoughts. 😉