
Better Bash Scripts: 3 Tips
Bash is the most popular shell scripting language. This article is targeted at software engineers for whom Bash is not a core competency. Bash is here to stay, and we would do well to invest time to brush up on Bash skills.
Here are three simple changes they can make now for more robust and reliable Bash scripts.
1. Use set -e

This script backs up bank_records.txt by copying it to /backups/.
echo "Backing up your bank records"
cp bank_records.txt
/backups/bank_records.txt
echo "successful"Suppose the cp operation fails because the directory /backups/ doesn’t exist. Let’s run it:
$ ./backup.sh
Backing up your bank records
cp: /backups/bank_records.txt: No such file or directory
successfulThis is not right! It prints backup “successful”, and the script exits with code 0 (meaning success). This is because the last executed command (echo “successful”) exits code 0.
By default, Bash continues to run all commands in a script, regardless of the exit code of each command. This is frequently overlooked by software developers used to handling errors with exceptions (cue Java, Python and friends).
Exit on first error
Blindly continuing on after unchecked errors can have disastrous consequences. What if our backup script deletes the original bank_records.txt file after cp (which can fail)?
Adding set -e at the top of your script is a cheap way to avoid this. It tells Bash to exit immediately if any command exits with a non-zero.
set -e
echo "Backing up your bank records"
cp bank_records.txt /backups/bank_records.txt
echo "successfully backed up your bank records!"Running the updated script (/backups still missing):
$ ./backup.sh
Backing up your bank records
cp: /backups/bank_records.txt: No such file or directoryThe script exits with code 1 from cp‘s exit code without echoing “successful”.
2. Use set -u
Let’s improve our backup script. Instead of hardcoding /backups, we can pass in an argument to indicate the backup directory.
set -eecho "Backing up your bank records"
cp bank_records.txt $1/bank_records.txt
echo "successfully backed up to $1/bank_records.txt!"
E.g. to backup bank_records.txt to /Users/jackie/backups/:
$ ./backup.sh /Users/jackie/backups
Backing up your bank records
successfully backed to /Users/jackie/backups/bank_records.txt!Unset variables
What if we forget to provide the argument?
$ ./backup.sh
Backing up your bank records
cp: /bank_records.txt: Permission deniedWhoops! We tried to cp to /bank_records.txt. Because $1 (our argument) is unset, $1/bank_records.txt interpolates to /bank_records.txt. By default, unset variables interpolate to empty.
The unintended effects of unset variables could be costly. We can protect ourselves by disallowing the use of unset variables. Let’s add set -u:
set -e
set -uecho "Backing up your bank records"
cp bank_records.txt $1/bank_records.txt
echo "successfully backed up to $1/bank_records.txt!"
If we run it again, without arguments:
$ ./backup.sh
Backing up your bank records
./backup.sh: line 6: $1: unbound variableThe moment we try to use $1, script raises the error unbound variable.
3. Use traps

Let’s ensure that only a single backup.sh runs at a time. We use a temporary file as an exclusive lock (LOCKFILE):
set -e
set -uLOCKFILE=/var/tmp/backup.lock
if [ -f $LOCKFILE ]; then
echo "Another instance running, exiting now"
exit 0
fitouch $LOCKFILE
echo "Backing up your bank records"
cp bank_records.txt $1/bank_records.txt
echo "successfully backed up to $1/bank_records.txt!"
rm $LOCKFILE
Suppose the first time you run this script, you forget to provide the backup directory as argument. You remember, then try again:
$ ./backup.sh
Backing up your bank records
./backup.sh: line 6: $1: unbound variable$ ./backup.sh ~jackie/backups
Another instance running, exiting now
The first backup.sh exited without deleting the lock file because we never reach rm $LOCKFILE, as we exited early from unbound variable.
We can use trap to ensure that we always remove the lock file on exit, early or otherwise.
set -e
set -uLOCKFILE=/var/tmp/backup.lock
if [ -f $LOCKFILE ]; then
echo "Another instance running, exiting now"
exit 0
fi
trap "echo removing $LOCKFILE; rm -f $LOCKFILE" EXIT
touch $LOCKFILE
echo "Backing up your bank records"
cp bank_records.txt $1/bank_records.txt
echo "successfully backed up to $1/bank_records.txt!"
Just before we create the $LOCKFILE (touch), we use trap to register a command to always run at EXIT. If we run the script now:
$ ./backup.sh
Backing up your bank records
./backup.sh: line 19: $1: unbound variable
removing /var/tmp/backup.lock$ ./backup.sh ~jackie/backups
Backing up your bank records
successfully backed up to /Users/jackie/backups/bank_records.txt! removing /var/tmp/backup.lock
Now we always remove “/var/tmp/backup.lock”, at every single exit.

Originally published at infinitydevops.com on September 3, 2018.