Writing robust bash scripts#
Bash scripts are useful for automating tasks and for running batch jobs. However, the default behavior of bash is to keep running when errors happen, and that can result in undesirable behavior such as running programs with the wrong settings, analyzing bad data, and worse. This page is written on the premise that it is better to fail loudly than to generate bad results or do bad things quietly.
However, because of the many, many bash pitfalls, using a more robust programming language when performing more complex tasks is recommended.
Improving default bash behavior#
This section describes several options that can make bash scripts behave in a more reasonable manner.
Warning
While it is possible to set these options in your shell, this is
not recommended since it will break scripts not designed with these
options in mind and can result in your terminal closing every time
you make a typo. For the same reason you must not set these options
in scripts that you import into your shell using the source
or
.
command.
Prevent use of undefined variables#
Variables are used for a variety of purposes in bash, including to access slurm options in batch scripts. However, unlike in most programming languages, it is not an error to access a variable that does not exist:
$ cat myscript.sh
#!/bin/bash
MY_VARIABLE="record"
echo "Tourist: I will not buy this ${MY_VARIABL}, it is scratched."
echo "Clerk: Sorry?"
$ bash myscript.sh
Tourist: I will not buy this , it is scratched.
Clerk: Sorry?
Note how the script keeps executing even though we made a mistake. A common mistake is therefore to misspell variables in scripts and have bash silent do the wrong thing.
While there are cases where it is useful to allow missing variables,
most of the time this is a mistake. To prevent this, you can set the
nounset
option, which causes bash to terminate on unset variables:
$ cat myscript.sh
#!/bin/bash
set -o nounset # Exit on unset variables
MY_VARIABLE="record"
echo "Tourist: I will not buy this ${MY_VARIABL}, it is scratched."
echo "Clerk: Sorry?"
$ bash myscript.sh
test.sh: line 4: MY_VARIABL: unbound variable
This not only tells us that there is a problem with our script (and where!), but it also stops bash from doing any more damage.
Note
Should you want to allow a variable to be unset while using
nounset
, you can use the ${name:-default}
pattern, where
name
is the name of a variable and default
is the text you
want to use if name
is not set. To match the default behavior of
bash simply use ${name:-}
.
Stop running on program failures#
By default, bash (and hence Slurm) will continue to execute a script even if a command fails. If this is not detected, then it can lead to partially or wholly corrupt data:
#!/bin/bash
# 1. Create some data
echo "I wish to complain about this dog what I purchased not half an hour ago from this very boutique." > sketch.txt
# 2. Process the data (badly)
sed -i -e's# dog # parrot ' sketch.txt
# 3. Etc.
gzip sketch.txt
This produces the following output:
$ ls
my-sketch.sh
$ bash my-sketch.sh
sed: -e expression #1, char 16: unterminated `s' command
$ ls
my-sketch.sh sketch.txt.gz
$ zcat sketch.txt.gz
I wish to complain about this dog what I purchased not half an hour ago from this very boutique.
In more complicated scripts and/or if slurm logs are not carefully vetted, this can lead to completely unexpected results.
There are several ways to handle these kinds of errors. We call exit
with the argument (exit code) 1 to indicate to Slurm that the command
failed.
# 1. Exit if command fails, but nothing else
sed -i -e's# dog # parrot ' sketch.txt || exit 1
# 2. Manually handle the failure
if ! sed -i -e's# dog # parrot ' sketch.txt; then
echo "We're closin' for lunch."
exit 1
fi
# 3. Ignore failures, if the command is expected to fail sometimes.
# This should be used with care!
sed -i -e's# dog # parrot ' sketch.txt || true
This, however, does not work well if you wish to pipe commands:
if ! sed -i -e's# dog # parrot ' sketch.txt | gzip > sketch.txt.gz; then
echo "We're closin' for lunch."
exit 1
fi
Running this code does not print We're closin' for lunch.
, because
the gzip
command succeeds even if sed
fails.
To mitigate these problems, we can make use of the following options:
#!/bin/bash
# Abort on unhandled failure in pipes
set -o pipefail
# Ensure that custom functions inherit these options
set -o errtrace
# Print debug message and terminate script on failures
trap 's=$?; echo >&2 "$0: Error on line "$LINENO": $BASH_COMMAND"; exit $s' ERR
# 1. Create some data
echo "I wish to complain about this dog what I purchased not half an hour ago from this very boutique." > sketch.txt
# 2. Process the data (badly)
sed -i -e's# dog # parrot ' sketch.txt
# 3. Etc.
gzip sketch.txt
Running this script produces the following, helpful output:
$ ls
my-sketch.sh
$ bash my-sketch.sh
sed: -e expression #1, char 16: unterminated `s' command
sketch.sh: Error on line 13: sed -i -e's# dog # parrot ' sketch.txt
$ ls
my-sketch.sh sketch.txt
Prevent bash from updating running scripts#
Putting it all together#
The following bash script template combines the suggestions above and thereby helps avoid some of the pitfalls of using bash
#!/bin/bash
# FIXME: SBATCH commands go here!
{
set -o nounset # Exit on unset variables
set -o pipefail # Exit on unhandled failure in pipes
set -o errtrace # Have functions inherit ERR traps
# Print debug message and terminate script on non-zero return codes
trap 's=$?; echo >&2 "$0: Error on line "$LINENO": $BASH_COMMAND"; exit $s' ERR
# FIXME: Your commands go here!
# Prevent the script from continuing if the file has changed
exit $?
}
Note however that is not guaranteed to catch all errors (see the bash pitfalls page for more information) and it is therefore recommended to use a more robust programming language or proper pipeline for more complicated tasks.
Checking your scripts for common mistakes#
In addition to implementing the suggestions listed on this page, it is recommended that you use the ShellCheck to check your bash scripts for common mistakes.
For example, if we run shell check on the very first script shown on this page:
$ module load shellcheck
$ shellcheck myscript.sh
In myscript.sh line 2:
MY_VARIABLE="record"
^---------^ SC2034 (warning): MY_VARIABLE appears unused. Verify use (or export if used externally).
In myscript.sh line 3:
echo "I will not buy this ${MY_VARIABL}, it is scratched."
^-----------^ SC2153 (info): Possible misspelling: MY_VARIABL may not be assigned. Did you mean MY_VARIABLE?
Running commands in Snakemake#
If you are using Snakemake to run your bash commands, then you are
already running commands in a "strict" bash mode, namely with set
-euo pipefail
. This sets the nounset
and pipefail
options
mentioned above, as well as the errexit
option that is equivalent to
the trap
command above but which prints less information about the
failure:
$ snakemake
sed: -e expression #1, char 16: unterminated `s' command
[Thu Aug 29 11:26:32 2024]
Error in rule 1:
jobid: 0
input: my-input.txt
output: my-output.txt
shell:
sed -i -e's# dog # parrot ' my-input.txt > my-output.txt
(one of the commands exited with non-zero exit code; note that snakemake uses bash strict mode!)
Note, however, that this does not apply to bash scripts that you execute in your snakemake pipeline!
Snakemake also includes support for automatically quoting filenames by
using the :q
modifier to variables: Instead of cat {input} | gzip
> {output}
simply write cat {input:q} | gzip > {output:q}
. This is
the equivalent of cat "{input}" | gzip > "{output}"
but also handles
cases like multiple filenames.