Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Fish Lists and Argument Expansion: Preserve Values Without Word Splitting

Use Fish lists, indexes, slices, path variables, and command substitutions without losing empty values, splitting filenames, or multiplying arguments unexpectedly.

Fish variables are lists of strings, not Bash-style scalar strings that later undergo word splitting. That changes how scripts should construct command arguments: a list element containing spaces remains one argument, while expanding two lists in the same token can generate every combination of their elements. The model is safer for filenames when used deliberately, but it has its own edge cases around empty lists, empty strings, command substitution, and path variables.

The key test is not “does the printed output look right?” It is “what exact argument vector reaches the command?” Use count, printf, and a small argument-dumping function to inspect list length and element boundaries before building destructive or batch commands.

Distinguish an empty list from one empty string

set name with no values creates a list with zero elements. set name "" creates a list with one element whose contents are empty. Those states behave differently under expansion and in command argument counts. A defined name is not necessarily non-empty, and a one-element empty list is not the same as an absent argument.

set -l no_values
set -l one_empty_string ""
set -l two_values alpha "two words"

printf 'counts: %s, %s, %s\n' \
    (count $no_values) (count $one_empty_string) (count $two_values)

When an optional argument can intentionally be empty, keep that as an explicit list element and test it with a quoted expansion or string length. When absence means “do not pass the flag,” keep the list empty and append the option only when the condition is true. This avoids sending a command a stray zero-length argument or a literal placeholder.

Use set -q name to test whether a variable is defined, set -q name[1] to test whether it has an element, and count $name to count the elements. A defined empty-string value may still have one element. For testing whether values contain nonzero characters, use a deliberate string test rather than assuming variable existence proves content.

Let list elements carry spaces as one argument

Expanding an unquoted Fish list passes one list element as one command argument. Fish does not split a value again on spaces or tabs. This is why a list is a natural way to build argument vectors:

set -l grep_args -r "my string" --include '*.fish'
command grep $grep_args .

The value my string remains a single element and therefore a single argument. The shell does not reinterpret whitespace inside it as an argument separator. This differs from Bash code that expands a scalar unquoted and then performs word splitting. It also means you should not insert shell-quoted text into a string and expect Fish to parse that text back into several arguments. Keep arguments as list elements from the beginning.

For diagnostics, a tiny helper can print each argument on its own line with delimiters:

function show_args
    for arg in $argv
        printf '<%s>\n' $arg
    end
end

show_args alpha "two words" ""

The output makes boundaries visible, including the empty element. Do not use echo $list as a test of argument cardinality; it formats multiple inputs into display text and can hide whether spaces came from one element or several.

Remember list expansion can create a Cartesian product

Fish combines multiple expansions in a token across all combinations. If one list contains two prefixes and another contains three suffixes, the combined token can produce six results. That behavior is useful for intentional combinations, but it can multiply command arguments unexpectedly if independent lists are expanded next to each other.

set -l prefixes debug release
set -l architectures x86_64 arm64

printf '%s\n' $prefixes-$architectures

The token combines each prefix with each architecture. If the application actually needs pairwise matching, do not write two independent list expansions and assume Fish will zip them. Iterate by a shared index after validating that both lists have the expected length, or create each combined value in a loop where the pairing rule is explicit.

set -l prefixes debug release
set -l architectures x86_64 arm64

if test (count $prefixes) -ne (count $architectures)
    printf '%s\n' 'input lists must have equal lengths' >&2
    return 2
end

for i in (seq (count $prefixes))
    printf '%s-%s\n' $prefixes[$i] $architectures[$i]
end

This example uses one-based indexes, which match Fish list indexing. The length check prevents an invalid position when inputs are not paired. In a function, return exits that function; at a top-level script, use an explicit script exit path instead of copying the fragment unchanged.

Use one-based indexes and explicit slices

Fish list indexes start at 1. Negative indexes count from the end, so -1 means the last element. A slice can select several indexes or a range. These operations apply to list elements, not to bytes or characters inside one string.

set -l servers api worker scheduler
printf 'first=%s last=%s\n' $servers[1] $servers[-1]
printf 'middle=%s\n' $servers[2..3]

Use count before indexing when the input can be empty, and verify indexes received from users or external commands. Negative indexes can be convenient for “last item” logic, but they do not validate that a list has the minimum length your workflow requires. For destructive operations, resolve the selected element and print a reviewable plan before executing it.

When a value is itself a single string containing separators, use an explicit string operation to turn it into a list. string split splits on a chosen delimiter, while string split0 handles NUL-delimited command output. Do not split arbitrary text on spaces if quoted filenames or values may contain spaces.

Handle command substitution as a list boundary

By default, Fish command substitution splits output on newline characters, not on every whitespace character and not according to $IFS. Each output line becomes an argument. This preserves spaces in ordinary filenames but cannot safely represent filenames containing newlines. For paths obtained from a filesystem, prefer a NUL-delimited producer and string split0.

set -l paths (find . -type f -print0 | string split0)
for path in $paths
    printf 'candidate: <%s>\n' $path
end

The NUL separator preserves path boundaries, including embedded spaces and newline characters. NUL itself cannot occur inside a Unix filename, which makes it a suitable delimiter. The string-splitting command must be the final command in the substitution pipeline for Fish to use its split output as the list boundary. Quoting a command substitution changes the result: the output is passed as one argument, and trailing empty lines are removed. Choose the form based on whether the consumer needs lines, one joined string, or NUL-delimited records.

Avoid capturing unbounded command output into a variable. Fish documents a default size limit for command-substitution data; when it is exceeded, the outer command does not run. Large data should flow through a pipe or a temporary file rather than being materialized as a shell list. This is both a memory and correctness boundary.

Treat PATH variables as colon-serialized lists

Fish automatically treats variable names ending in PATH as path variables. Internally they behave as lists, and when quoted or exported they are joined using colons for compatibility with the process environment. An element containing a colon cannot be represented unambiguously in that colon-delimited format.

set -gx PATH $PATH /opt/acme/bin
command -v acme-tool

set --show PATH

For one Fish process, a global exported path list is enough. To persist a user path across sessions, use Fish’s path configuration mechanisms rather than repeatedly appending the same directory from multiple files. Inspect the result after a fresh shell starts, because a login manager, terminal, package manager, and Fish configuration can all contribute to the inherited or configured search path.

Ordinary lists exported to an environment variable are serialized into a single string, with Fish using spaces for non-path lists and colons for path variables. That representation does not preserve arbitrary list structure for an external program. If a tool accepts repeated flags, pass separate command arguments; if it requires an environment string, document the delimiter and escaping rules as part of that tool’s interface.

Build commands with lists, not command strings

Avoid this fragile pattern:

set command_line "tool --label '$label' $path"
eval $command_line

It converts structured arguments back into source text and asks Fish to parse them again. Quotes inside a variable do not regain their original syntactic role, and an eval path can transform data into shell grammar. Keep the executable and each argument separate:

set -l args --label "$label" -- $path
command tool $args

If an optional flag depends on user input, append its values as list elements conditionally. If you need to log a command, print a human-readable representation separately from the actual argument list. A display string is not an executable serialization format.

Acceptance checks for list-heavy scripts

Use a fixture with filenames containing spaces, quotes, wildcard characters, and newlines. Include zero matches, one match, and multiple matches. For each code path, inspect count, show the elements with explicit delimiters, and test the exact argument vector with a small helper before running the real tool. Confirm that empty lists omit optional arguments and one empty-string value stays one argument when that is intended.

Test any two-list expansion that is supposed to pair values. Verify the number of combinations produced by the actual expression, and replace accidental Cartesian products with indexed iteration. Test path list changes in both Fish and an external child process to confirm the internal list and colon-joined environment agree.

Fish lists reduce a large class of word-splitting bugs, but they do not remove the need to reason about argument boundaries. Keep data as list elements, model zero values separately from an empty string, use the right command-substitution delimiter, and avoid converting an argument vector back into source text.

Related:

Sources:

Comments