Safer Scripting: Because Who Needs Errors, Anyway?
When it comes to bash scripting, safety should always be your top priority. After all, you don’t want your script to go rogue and wreak havoc on your system, do you? To avoid common errors and ensure your script behaves as expected, start with a solid foundation. Here’s a prolog that’ll take care of two very common mistakes:
#!/bin/bashset -o nounsetset -o errexitThe first line, #!/bin/bash, specifies the interpreter that should be used to run your script. The second line, set -o nounset, prevents your script from referencing undefined variables, which can lead to unexpected behavior. The third line, set -o errexit, ensures that your script exits immediately if any command fails, preventing further errors from propagating.
But what if you need to tolerate a failing command? No worries! You can use the following idiom to ignore errors:
if ! <possible failing command> ; then echo "failure ignored"fiSome Linux commands also have options that can suppress failures. For example, mkdir -p will create a directory and its parents if they don’t exist, without complaining if the directory already exists. Similarly, rm -f will remove a file without prompting for confirmation, even if the file doesn’t exist.
Additional Tips and Tricks:
-
Use
set -o pipefailto catch errors in pipelines. This option will exit the script if any command in the pipeline fails. -
Use
set -o xtraceto enable tracing, which will print each command before executing it. This can be helpful for debugging. -
Use
set -o verboseto enable verbose mode, which will print each command and its arguments before executing it.
Next, I’ll move on to the section on functions in bash. Here’s the rewritten section:
Functions in Bash: Because Code Reuse is a Good Thing
Bash functions are a great way to organize your code and make it more reusable. They’re essentially blocks of code that can be called multiple times from different parts of your script. Here’s an example of a simple function that extracts comments from a file:
ExtractBashComments() { egrep "^#"}You can call this function like any other command, passing a file as an argument:
cat myscript.sh | ExtractBashCommentsOr, you can use it in a pipeline:
comments=$(ExtractBashComments < myscript.sh)Additional Tips and Tricks:
-
Use meaningful names for your functions to make your code more readable.
-
Use the
localkeyword to declare local variables within your functions. -
Use the
readonlykeyword to declare read-only variables within your functions.
Related Reading
- Bash for loops sequential counting
- Bulk rename files in bash
- Differences Between nohup, disown, and & in Linux
- Executing Commands with Asterisks in Docker
- Linux Bash Tips and Tricks pt1
The Gotchas That’ll Ruin Your Tuesday
Functions look clean on paper. Then you run your script in CI at 2 AM and discover bash was waiting to humble you. Here are the ones that get people the most.
local doesn’t inherit errexit
This one is subtle and genuinely evil. When you use local to declare and assign a variable in one line, the assignment’s exit code is always 0 — even if the right-hand side fails. Your set -o errexit won’t save you here.
#!/bin/bashset -euo pipefail
get_hostname() { local hostname=$(some_command_that_fails) # this does NOT abort the script echo "$hostname"}The fix is to split the declaration and assignment:
get_hostname() { local hostname hostname=$(some_command_that_fails) # NOW errexit fires on failure echo "$hostname"}One extra line. Saves you an hour of “but why didn’t it exit??”
Subshell variable scope will gaslight you
Functions that modify variables only affect the current shell — but pipelines spawn subshells, and that’s where things get weird. If you pipe into a while read loop, any variables you set inside it vanish the moment the loop exits.
count=0cat some_file.txt | while read -r line; do count=$((count + 1)) # increments inside the subshell, not in your scriptdoneecho "Lines: $count" # prints 0. every. time.Use process substitution instead:
count=0while read -r line; do count=$((count + 1))done < <(cat some_file.txt)echo "Lines: $count" # actually worksTrap your exits like you mean it
If your script creates temp files, locks, or modifies shared state, you need a cleanup trap. Without one, a Ctrl+C or an uncaught error leaves debris behind.
TMPFILE=$(mktemp)
cleanup() { rm -f "$TMPFILE"}trap cleanup EXIT # fires on exit, error, AND Ctrl+C
# rest of your scriptecho "doing work with $TMPFILE"trap ... EXIT is the one you want. It covers normal exits, errexit kills, and signals. You can stack multiple cleanup actions by defining them all in the cleanup function.
Quote everything. No, actually everything.
"$variable" vs $variable seems like nitpicking until a filename with a space in it blows up your deploy script. The rule is simple: if it’s a variable, quote it. If it’s a glob, know what you’re doing. Everything else — quote it anyway.
# Wrong — breaks on filenames with spacesfor f in $files; do rm $f; done
# Rightfor f in "${files[@]}"; do rm "$f"; doneArrays with [@] and proper quoting are a bit more to type. Your 3 AM self will still thank you.