I would hazard a guess that most people who are writing a quick Bash script end up writing their scripts something like this:

service_name=$1

sudo rm -rf /etc/$service_name

In this example you have some clean up that needs to happen on the configuration files for some service. Assuming that the user correctly gives the service name then this is fine. However this is incredibly dangerous and if done incorrectly (or if someone is feeling malicious) can do some really bad things. Let’s look at what happens if someone just makes a small typo.

If someone ran our sample script (we’ll call it clean.sh) like so ./clean.sh "cloud /" what this would end up doing is running the rm command as sudo rm -rf /etc/cloud /. This would correctly delete the /etc/cloud directory but then just delete everything from the root of the filesystem. That’s not what we wanted!

By default, when you pass a bare variable using something like $var_name in Bash, it’ll insert that string exactly as is. If there were any spaces those would be included as well. That’s why in our example the rm command is trying to delete /. It leaves the space in the arguments and rm interprets those as two different arguments.

The safer way to write this (ignoring actual sanity checks on the input for a moment) would be like so:

service_name="$1"

sudo rm -rf "/etc/$service_name"

In this case, Bash would have looked for a directory that had a space at the end of it which probably wouldn’t match to anything and the command would fail saying that no such directory exists. This is what we want.

Let’s look at another way this could go wrong.

./install.sh --prefix $prefix

If for some reason prefix isn’t set (maybe its given by the user or as output from some other command that failed) then this command is functionally the same as just calling ./install.sh --prefix with nothing at the end. Hopefully that doesn’t do anything bad for you. It can get even worse when you have additional input.

./install.sh --prefix $prefix all

Now it’s going to install stuff to the all directory local to wherever it’s being run from. Not what you want.

If you had instead used quotes it would see that prefix isn’t set.

./install.sh --prefix "${prefix}" all

# Equivalent to:

./install.sh --prefix '' all

Bonus points if your command allows for the = symbol. ./install.sh --prefix="${prefix}" all

Recommendation

Given this example, my recommendation is to always quote your Bash variables by default (make sure to use double quotes cause they do interpolation whereas single quotes do not). This is going to prevent foot guns and silly mistakes from happening.

Exceptions

The only times that I can think of where not using quotes is helpful is when you want the variable to potentially be expanded.

Expanding arguments

function main() {
    local name="$1"; shift

    echo "Hello, ${name}!"
}

main $@

With this, all arguments passed to the script (the @ variable is magically set by Bash to include all arguments) as is directly to main. I personally like this pattern so that you can have a clear entrypoint for your script.

You could also use this as a way to build up a set of arguments dynamically to be passed somewhere else.

args="${TARGET:-all}"

if ! [ -z "${PARALLEL}" ]; then
    args="-j4 ${args}"
fi

make ${args}

In this case it’ll use 4 processes if the PARALLEL variable is set and could expand to make -j4 all.

Honestly though, I don’t do this pattern a ton.

Loops

The other case where this can be helpful is when you need to loop over the contents of a variable.

config_files="$(ls /etc)"

for f in ${config_files}; do
    echo "'${f}'"
done

This will execute the loop once for each file/directory in /etc. If you had quoted ${config_files} in the loop it would only execute one time.