Understanding Bash

10 minute read     Updated:

Kasper Siig %     Kasper Siig

Bash scripts give you the ability to turn a tedious series of commands into an easily runnable and repeatable script. With many real-world use cases, like using a bash script to run a continuous deployment process, create a series of files in a folder, or download the contents of several URLs, it’s worth your time to make sure bash scripting is in your programming toolbox.

When you’re done with this article, you’ll not only be able to write bash scripts, but you’ll be able to write them using today’s accepted best practices.

Use the Right Shebang

The very first thing you’ll see at the top of every (well-written) bash script is what’s called a shebang. I’ll walk you through a couple of them here.

#!/bin/bash

The most common shebang is the one referring to the bash executable:

#!/bin/bash

Essentially it tells your terminal that when you run the script it should use bash to execute it. It can be vital since you may be using a different shell in your machine (zsh, fish, sh, etc.), but you designed the script to work specifically with bash. In many cases, it doesn’t matter what shell you’re using, but there can be some very noteworthy differences in how they work, leading a script to work in bash but not sh, for example.

#!/user/bin/env bash

If you use the previous shebang, it’s crucial that you give the executable’s absolute path. You should be aware of this since there is an alternative, where you use the bash executable found in the $PATH. You can do so by writing:

#!/user/bin/env bash

Some people like to customize their systems, either their personal system or production servers, resulting in the bash executable not being located in /bin/bash every time. Use the above line if you can’t be sure that the bash executable will be located in the same path when this script is run.

Understand Common Sets

When you run a bash script, it will always run in a new subshell. This means that any unique configurations you have in your current setup will not be used within the script execution. It also means that you can customize the environment that the script is running in without worrying about how your terminal will be affected.

One way to change this environment is to use the set command. I’ll go over the four most common ones and where they’re useful. I’ll show the short form for these sets in the examples throughout this article, but keep in mind that there are also long-form versions. I’ll mention those briefly.

set -u

By default, bash doesn’t do a lot of error handling. That’s left up to you. So if you want to have your script exit at a certain point, you have to define it. For example, you may have the following script:

#!/bin/bash
echo $TEST
echo Hello World

If you run the script as shown above, it’ll give you the following output:

Hello World

See how it doesn’t complain that the $TEST variable is not set? You can change that. Setting the set -u (short form of set -o nounset) command initially, you’re telling bash that you want it to fail if a variable is not set.

Script:

#!/bin/bash
set -u
echo $TEST
echo Hello World

Output:

line 3: TEST: unbound variable

Without set -u, bash will use an empty string instead of the unset variable. When running echo $TEST, that isn’t too dangerous. However, you may be running a command like rm -rf /$TEST to define a path you want to delete. In this case, without set -u, you would end up deleting your entire file system (which there’s no way to recover by default).

set -x

You’ll likely at some point have a big script where it’s tough to keep track of not just which commands are running what, but also which commands are outputting what. This is where set -x comes to the rescue. Alternatively, you can write this as its long form, set -o xtrace.

When using set -x, you get the following script and output.

Script:

#!/bin/bash
set -x
echo Hi
echo Hello World

Output:

+ echo Hi
Hi
+ echo Hello World
Hello World

set -e

Sometimes you want to make sure that the entire script fails if one of the commands fails. This is not the default behavior in bash. You can see in the manual that without any set options, bash is running without much error handling.

To make sure the script fails, you should use set -e (also known as set -o errexit), probably the most common one.

Script:

#!/bin/bash
set -e
foo
echo Hello World

Output:

line 3: foo: command not found

set -eo pipefail

Finally, we can make the script fail if a command in a pipeline fails. Usually, bash only looks for the exit code from the last command in a pipeline. If that’s 0, it’ll continue just fine. Exit code 0 is what we want, since in bash that means success.

Let’s use the following script as an example:

#!/bin/bash
foo | echo Hello World
echo Hi

set -eo pipefail will turn the output from:

Hello World
line 2: foo: command not found
Hi

into:

Hello World
line 2: foo: command not found

The reason you may want the script to fail if a pipeline fails is the same as earlier with set -u. Let’s modify the scenario a bit. You have the following in your script:

FILE_PATH=$(cat /tmp/path.txt | sed 's/_/-/g)
rm -rf /$FILE_PATH

Note: sed is a search-and-replace command. In this case it replaces underscores with dashes.

The intention is that /tmp/path.txt contains tmp_file.txt. Assume that the file /tmp-file.txt exists on the system. In this case the script will work perfectly and delete /tmp-file.txt. But what if /tmp/path.txt doesn’t exist? cat /tmp/path.txt will fail, but the script won’t. Now you’ve deleted your entire filesystem, but set -eo pipefail will prevent this.

Sets in Summary

Set Long form Description
set -u set -o nounset Exits script on undefined variables
set -x set -o xtrace Shows command currently executing
set -e set -o errexit Exits script on error
set -eo pipefail set -eo pipefail Exits script on pipeline fail

Use Error Checking Tools

Although you may be familiar with all the best practices, it can be tough to remember them all when your script is coming to life. Luckily there are tools available to help, like ShellCheck. ShellCheck has both a browser version and a command-line tool, but for this article, let’s work with the command-line version. You can find installation instructions on GitHub.

We’ll use the following script as an example:

echo "What's your name?"
read NAME
echo Hello $NAME

By saving this in a script in a file called greeting.sh and running shellcheck greeting.sh, you get the following output in your terminal:

In greeting.sh line 1:
echo "What's your name?"
^-- SC2148: Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive.


In greeting.sh line 2:
read NAME
^--^ SC2162: read without -r will mangle backslashes.


In greeting.sh line 3:
echo Hello $NAME
           ^---^ SC2086: Double quote to prevent globbing and word splitting.

As you can see, shellcheck doesn’t just tell you what you need to change, but also why it needs to be changed. This is a valuable resource, not just for improving your scripts, but also to get better at writing them in the first place.

With these tips, you’ll end up with the following script:

#!/bin/bash
echo "What's your name?"
read -r NAME
echo Hello "$NAME"

Understand Variable Naming and Declaration

As you saw earlier, we tried to use the $TEST variable. Variables can open up a whole world of opportunities, but they can also be tricky to work with. Let’s go over some of the common scenarios for working with variables.

Assigning Variables

Assigning a variable in bash is reasonably straightforward, using the = symbol. Here’s an example of assigning “Hello World” to a $TEST variable:

$ MSG="Hello world!"
$ echo $MSG
Hello world!

Using Variables Inside Strings

There are multiple ways to use a variable that you’ve assigned a value. As an example, we’ve assigned foo=uname.

Double Quotes

If you want to echo the contents of a variable, then use double quotes. It will expand what’s inside the variable and print that to the screen.

$ foo="uname"
$ echo "$foo"
uname

Single Quotes

In some cases, you don’t want to output a variable’s contents, but maybe write an explanation of what that variable is used for. To avoid expansion, use single quotes:

$ foo="uname"
$ echo '$foo'
$foo

This also means that you don’t have to manually escape the $ symbol, which you otherwise would need to in the case of double quotes.

Backticks

The third option for using a variable is backticks. Use this when you want the contents of the variable to be run as a shell command:

$ foo="uname"
$ echo `$foo`
Linux

Using Curly Brackets

You can get away with merely referring to a variable by writing $FOO. However, you may want to refer to a variable inside a string or concatenate it with another. Take a look at the following example:

$ FOO="Hel"
$ echo "$FOOlo World"
Hello World

In this case, bash would try to find the variable $FOOlo, but we just wanted to print “Hello world.” To make this work, you will have to do the following:

$ FOO="Hel"
$ echo "${FOO}lo World"
Hello World

This is most likely useful when you want to use a variable to define a path, like /opt/${ENVIRONMENT}_build.txt. Without curly brackets, the script would try to look up $ENVIRONMENT_build.

Properly Set Permissions

One of the pitfalls that I remember running into time and time again when I started making bash scripts was remembering that permissions had to be set right. See, when you make a file with, for example, touch, it gives read/write permissions to the owner and read rights to everyone else. This means that you’ll get a permission denied error when you try to run the script.

Luckily this is easily fixed. Run chmod +x script.sh, and now everyone is allowed to run the script.

However, do be aware that changing permissions can impose security risks. Read more about Linux file permissions before you start changing permissions blindly.

Ensure Readability

One of the biggest pitfalls that newcomers run into is forgetting about readability. It’s easy to get caught up in wanting to have a working script, and maybe you’re even used to running everything manually in the terminal, where you want to type as little as possible.

When it comes to scripts, you want to make sure that you can still easily remember what’s happening six months down the line. An easy way to do this is by using more extended options (--quiet instead of -q), using longer variable names (MESSAGE instead of MSG), and writing comments.

You can write commands using a hash mark, after which you can write your comment, like so:

# Below line will echo "Hello World!"
echo "Hello World!"

Understand Your Script in Relation to CLI

When reading this article, you may have noticed that many code examples are being run straight in the terminal rather than written as a bash script. There’s a good reason for that! You can write everything you write in a bash script directly in the terminal.

There is one significant difference between executing a script and typing the commands in your terminal. When you run a script, it’ll start up a new, clean shell in which the script will run. This means that no variables set in your terminal will interfere with your script.

For example, if you set TEST="hello" in your shell and run echo $TEST inside a script, it will print nothing to your screen.

Conclusion

At this point, you should be ready to venture into the exciting world of bash scripting. You’ve learned about common shebangs, what set does, and how it can improve the error handling of your scripts, as well as understanding some general pitfalls developers run into with bash.

So go ahead and automate those annoying commands you’ve been typing out every day. Tired of manually going into your browser and finding the git repository you’re working on? Make a script to parse the remote git URL and open it automatically. Maybe you have to rename a bunch of files. Make a script that can loop through them and rename them. The world is your oyster.

Kasper Siig %
Kasper Siig

As a DevOps engineer, Kasper Siig is used to working with a variety of exciting technologies, from automating simple tasks to CI/CD to Docker.

Categories:

Updated: