fish and bash Variable Expansion

Published: January 21, 2024, updated: January 17, 2025

Having used ShellCheck countless times to polish my bash scripts, I write variable and command expansion like so:

# A variable containing a string does not need quotes
PATH_TO_THING=./thething.txt

# Setting a variable to be the value of a another variable (variable expansion)
# must be escaped in quotes, because of spaces being interpreted as separators.
INPUT="$1.txt"

# Command expansions must be escaped to
OUTPUT_WITH_DATE="$INPUT/file_$(date -I)"

# If we don't escape here, we might accidentally feed ls two commands
ls "$OUTPUT_WITH_DATE"
# If $OUTPUT_WITH_DATE is /Users/A user name/file_2024-01-21, and we don't escape,
# ls will see it as the equivalent of calling separately
ls /Users/A
ls user
ls name/file_2024-01-21

bash applies a string containing spaces as separate arguments.

The reason for this is that spaces and newlines have a specific role in bash related to the Internal Field Separator (IFS).

This can lead to subtle bugs, and sometimes destructive behavior. Imagine calling rm like so:

# Woops, a space was accidentally entered here:
path=/home/user/.local /etc
# And not knowing any better, we call
rm -rf $path
# Then the above rm call will be equivalent to
rm -rf /home/user/.local
rm -rf /etc

In this example, instead of deleting etc in our user’s home directory’s .local directory, we delete the entire .local folder and /etc to go with. The second half might not run if we’re not a superuser or similar, but the loss of a .local folder might cause destructive loss of important data. Let’s back up our systems and test our backup strategies.

I have successfully destroyed a Debian installation before, by smuggling a space into a chown call and making my root /etc folder unusable by accident. This in turn made my Debian installation unbootable.

At least, when typing out paths in a command prompt, you can see potential space-traps and quote your arguments accordingly. But when using variable expansion, and not knowing whether variables contain spaces or not, it’s better to be on the safe side and always use quotes.

With the fish shell, it’s different. I’ve recently started writing a lot of scripts for my personal computer. I’ve also ported some other system administration scripts that I have been using before to use fish instead of bash.

I’m used to bash’s variable expansion gotchas and I use quotes whenever I can. fish has some subtle issues with quoting, as we now see.

fish variables are implicit arrays

Everything in fish is an array. Some of these arrays in turn are PATH arrays. fish interprets PATH arrays slightly differently.

Here, we define a function that reads out how many arguments it receives in fish. You can use it to examine how fish expands quotes. This is the function:

function count_args
  echo "There are "(count $argv)" arguments:"
  echo $argv
end

This prints:

$ count_args a a a
There are 3 arguments:
a a a

Which makes sense–we’re passing three arguments. What happens with quoted arguments is also no big surprise:

$ count_args "a a a"
There are 1 arguments:
a a a

Now, we set a variable to contain a single string, and again no big surprise:

$ set myvar "this is a single argument"
$ count_args "$myvar"
There are 1 arguments:
this is a single argument

fish now, gets rid of the necessity to quote things since it implicitly evaluates variable expansions as single arguments and we can conveniently leave out the quotes:

$ count_args $myvar
There are 1 arguments:
this is a single argument

Now we set a second variable to be an array containing more than one element. You can see that fish expands the values myvar to become 3 arguments.

# Note that no special syntax is needed to define an array
$ set myarray "several" "arguments" "are here"
$ count_args $myarray
There are 3 arguments:
several arguments are here

Since we quoted the arguments, fish expands them into three different arguments for our count_args function call. If we want to make sure that fish expands myarray into exactly one argument, we need to surround it with quotes:

$ count_args "$myarray"
There are 1 arguments:
several arguments are here

We need to apply bash-like defensive quotes only for variables where we know that they contain more than one item. Special semantics apply to path-like variables. One of these variables is PATH itself. Note the difference between the two expansions:

$ count $PATH
17
$ count "$PATH"
1

In the first case, counting the items in PATH gives us 17, since the PATH variable in this shell instance has 17 folders. When quoting the PATH variable expansion, we only get only one item. Again, in hindsight that totally makes sense, but since I reflexively put quotes everywhere, I was still surprised.

When printing arrays using echo, we won’t notice any differences in argument counts:

$ echo $myarray
several arguments here
$ echo "$myarray"
several arguments here

And so, we may thinking that we’re safe by just putting quotes around everything. This can introduce a subtle bug, as we see in the following:

fish variables can be path-like

The second gotcha is that some variables are path-like, like the PATH variable we looked in the previous section. We can declare our own path variable like so:

$ set --path mypath one two
$ echo $mypath
one two

If we try to echo mypath, we see a difference in behavior between quoted and non-quoted variable expansion:

$ echo "$mypath"
one:two
$ echo $mypath
one two

As noted in the help section on path-like variables, fish introduces special semantics for variables marked as path-like variables. We can turn any variable into a path-like variable by using the --path flag when calling set, just like in the preceding example.

fish maintains backward-compatible behavior with other programs that you invoke from a fish shell and inherit its environment. A colon is implicitly added every time fish expands a path-like variable inside quotes or similar.

If you watch out for this, it’s much more pleasant to work with paths in fish.

For example, when managing work-related documents, paths might contain spaces, or, worse, newlines, and it’s handy to have well-documented behavior for these. I was about to wag my finger at fish. Then, I realized that my current habits and workaround for legacy shell behavior are the issue. fish’s attempt at fixing them isn’t the issue.

Nowadays I prefer writing fish scripts over bash scripts, at least for my own purposes. If you instruct programs to use 0 byte values as separator (\0), then fish can turn these into fish arrays using string split0 as documented here.

Next time you try out fish, take note of these improved semantics, and perhaps you may consider making fish your daily driver like I have.

Tags

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index