← Back to DevBytes

Fish Scripting: Environment Variables Complete Guide

Environment Variables in Fish Shell: The Complete Guide

Environment variables are a fundamental building block of any shell environment, and Fish shell brings its own elegant, opinionated approach to handling them. Whether you're writing simple scripts or building complex developer workflows, understanding how Fish manages variables will make your shell experience smoother and more predictable.

What Are Environment Variables?

Environment variables are key-value pairs stored in memory that programs and shell processes can read. They carry configuration information like paths to executables (PATH), user preferences (EDITOR), system identifiers (HOME, USER), and custom project settings. In Fish, variables exist in two distinct layers: shell variables (visible only to the Fish shell itself) and exported environment variables (passed to child processes).

Fish treats every variable as a list by default — a design choice that simplifies handling multiple values and eliminates word-splitting headaches common in POSIX shells like bash.

Why Environment Variables Matter in Fish Scripting

Environment variables serve as the configuration backbone for nearly everything in a developer's daily workflow:

Variable Scopes in Fish

Fish offers four distinct scopes for variables. Understanding these is essential before writing any script:

Setting Variables: The set Command

Fish uses the set builtin for all variable operations. The basic syntax is straightforward but powerful:

# Set a simple variable (local by default inside functions, global otherwise)
set my_var "hello world"

# Set with explicit global scope
set -g my_global "visible everywhere in this session"

# Set a local variable inside a function
set -l my_local "only visible in this function"

# Set multiple values (creates a list)
set -g my_list "apple" "banana" "cherry"

To make a variable available to child processes, add the -x or --export flag:

# Export a variable so child processes can see it
set -gx EDITOR "nvim"

# Equivalent long-form
set --global --export EDITOR "nvim"

# Export a local variable (rare but valid)
set -lx TEMP_CONFIG "/tmp/myconfig"

Accessing Variable Values

Use the dollar sign prefix to reference variable values, just like in other shells:

echo $my_var
echo $EDITOR

# Access list elements by index (Fish lists are 1-indexed!)
set my_list "alpha" "beta" "gamma"
echo $my_list[1]   # prints "alpha"
echo $my_list[2]   # prints "beta"
echo $my_list[-1]  # prints "gamma" (negative indexing from the end)

# Get list length
echo (count $my_list)
# Or use the built-in count method
printf '%s\n' $my_list | wc -l

Fish also supports variable expansion in double-quoted strings but not in single-quoted strings:

set name "Alice"
echo "Hello, $name!"     # prints: Hello, Alice!
echo 'Hello, $name!'     # prints: Hello, $name! (literal)

Universal Variables: Fish's Superpower

Universal variables are one of Fish's most distinctive features. They persist across all Fish sessions and synchronize automatically. Fish stores them in a database file (typically ~/.local/share/fish/fish_variables) and uses file locking to prevent corruption across concurrent sessions.

# Set a universal variable — instantly available in ALL Fish sessions
set -U FAVORITE_COLOR "teal"

# Universal + exported (visible to child processes everywhere)
set -Ux JAVA_HOME "/usr/lib/jvm/java-17"

# Check the value from another terminal window
echo $FAVORITE_COLOR   # prints "teal"

Universal variables are ideal for:

Important caveat: Universal variables are stored separately from your Fish config files. If you want to document or version-control your universal variables, you should also record them in ~/.config/fish/config.fish or a dedicated configuration file.

Working with PATH in Fish

In Fish, PATH is a list variable, not a colon-delimited string. This is a significant departure from POSIX shells and makes path manipulation much cleaner:

# View PATH as individual entries (one per line)
printf '%s\n' $PATH

# Prepend a directory to PATH (higher priority)
fish_add_path /usr/local/bin
# Or manually with set
set -gx PATH /usr/local/bin $PATH

# Append a directory to PATH (lower priority)
set -gx PATH $PATH ~/.local/bin

# Remove a specific directory from PATH
set -gx PATH (string match -v '/some/directory' $PATH)

The fish_add_path command is the recommended way to modify PATH. It handles deduplication automatically and respects universal variables if PATH is universal:

# Add a path (deduplicated, prepended by default)
fish_add_path ~/bin

# Add a path only if it exists
fish_add_path --path ~/.cargo/bin

# Append rather than prepend
fish_add_path --append /opt/special/bin

# Move an existing path to the front
fish_add_path --move /usr/local/bin

Listing and Inspecting Variables

Fish provides several ways to inspect defined variables:

# List all variable names (including their values)
set

# List only variable names (without values)
set --names

# Show detailed information for a specific variable
set --show PATH

# Check if a variable exists (returns 0 if defined, 1 otherwise)
set -q my_var
# or
set --query my_var

# Use in conditionals
if set -q DEBUG_MODE
    echo "Debug mode is enabled"
end

The --show flag reveals the scope, export status, and individual list elements of a variable:

$ set --show PATH
$PATH: set in global scope, exported, with 5 elements
$PATH[1]: /usr/local/bin
$PATH[2]: /usr/bin
$PATH[3]: /bin
$PATH[4]: /usr/sbin
$PATH[5]: /sbin

Erasing Variables

Remove variables with the --erase flag. Be careful with universal variables — erasing them affects all sessions:

# Erase a global variable
set --erase my_global

# Erase a universal variable (affects ALL sessions!)
set --erase --universal FAVORITE_COLOR

# Erase an exported variable
set --erase --export EDITOR

# Erase a local variable (only valid inside its scope)
set --erase --local TEMP_CONFIG

Environment Variables in Functions

Inside Fish functions, variables default to local scope. This prevents accidental pollution of the global namespace:

function my_build_tool
    # This variable is local to the function automatically
    set build_dir "./build"
    
    # Explicitly local
    set -l temp_result (make -C $build_dir)
    
    # To set something globally from within a function, be explicit
    set -g LAST_BUILD_STATUS $status
    
    echo "Build complete in $build_dir"
end

Function arguments are automatically available as the list variable $argv:

function greet
    echo "Hello, $argv[1]!"
end

greet Alice    # prints: Hello, Alice!

# Access all arguments
function show_all_args
    printf 'Arg %d: %s\n' (seq 1 (count $argv)) $argv
end

show_all_args foo bar baz
# Arg 1: foo
# Arg 2: bar
# Arg 3: baz

Special Variables in Fish

Fish provides several built-in variables that carry important runtime information:

# Check if last command succeeded
if test $status -eq 0
    echo "Success!"
else
    echo "Failed with exit code $status"
end

# Show command duration
echo "That took $CMD_DURATION milliseconds"

# Use in prompt customization
function fish_prompt
    set -l last_status $status
    set -l duration $CMD_DURATION
    # ... build your prompt using these values
end

Exporting Variables to Child Processes

Only variables marked with -x (export) are passed to child processes. This is a deliberate design: Fish avoids leaking all shell variables into subprocess environments by default:

# This variable stays inside Fish
set -g INTERNAL_CONFIG "secret"

# This variable is visible to child processes
set -gx PUBLIC_CONFIG "visible"

# Demonstrate the difference
fish -c 'echo $INTERNAL_CONFIG'   # prints nothing
fish -c 'echo $PUBLIC_CONFIG'     # prints "visible"

# Check what a child process sees with env
env | grep PUBLIC_CONFIG          # shows PUBLIC_CONFIG
env | grep INTERNAL_CONFIG        # shows nothing

You can also set environment variables for a single command invocation without modifying your shell's state:

# Temporary environment for one command
VAR1="value1" VAR2="value2" some-command

# Or using the env command
env VAR1="value1" VAR2="value2" some-command

# In Fish, the first form works identically to other shells
NODE_ENV="production" PORT=3000 node server.js

Working with Arrays and List Variables

Fish variables are always lists. Even a "single" value is a list of one element. This eliminates word-splitting bugs:

# Setting a list
set -g colors red green blue

# Accessing elements (1-indexed)
echo $colors[1]    # red
echo $colors[2]    # green

# Slicing
echo $colors[2..3] # green blue

# Iterating over elements
for color in $colors
    echo "Color: $color"
end

# Appending to a list
set -a colors purple
# colors is now: red green blue purple

# Prepending to a list
set -p colors orange
# colors is now: orange red green blue purple

# Concatenating lists
set -g all_colors $colors $more_colors

# Converting list to string (joining with spaces by default)
echo "$colors"    # "orange red green blue purple"

# Join with custom delimiter
echo (string join ":" $colors)    # "orange:red:green:blue:purple"

Persisting Configuration Across Sessions

For variables that need to survive across Fish restarts, you have two main approaches:

  1. Universal variables — Use set -U for automatic persistence in the Fish variable database.
  2. Config files — Define variables in ~/.config/fish/config.fish for declarative, version-controllable persistence.

The recommended hybrid approach:

# In config.fish — set global/universal defaults that are version-controlled

# PATH additions (universal so they sync across sessions)
fish_add_path --path ~/.local/bin
fish_add_path --path ~/go/bin

# Editor preference (universal + exported)
set -Ux EDITOR "nvim"

# Project-specific variables (global, re-read on each session)
set -g PROJECT_ROOT ~/projects/myapp

# Secrets should NEVER go in config.fish — use universal variables
# set manually once: set -Ux API_TOKEN "secret-value"

Best Practices for Fish Environment Variables

Common Patterns and Recipes

Pattern 1: Conditional Defaults

# Set a default value only if the variable isn't already defined
if not set -q MY_CONFIG_DIR
    set -g MY_CONFIG_DIR ~/.config/myapp
end

# Or use a compact form with a default
set -q MY_CONFIG_DIR; or set -g MY_CONFIG_DIR ~/.config/myapp

Pattern 2: Loading Environment from a File

# Parse a .env-style file in Fish
function load_env --argument-names env_file
    for line in (cat $env_file)
        set -l parts (string split "=" $line)
        if test (count $parts) -eq 2
            set -gx $parts[1] $parts[2]
        end
    end
end

load_env .env

Pattern 3: Temporary Variable Scope

# Use begin/end blocks to limit variable lifetime
begin
    set -l temp_file (mktemp)
    set -l result (process_data > $temp_file)
    cat $temp_file
    rm -f $temp_file
end
# temp_file and result no longer exist here

Pattern 4: Debug Mode Toggle

# Set once: set -U DEBUG_MODE 1
function debug_log --argument-names message
    if set -q DEBUG_MODE; and test "$DEBUG_MODE" = "1"
        echo "[DEBUG] $message" >&2
    end
end

debug_log "Processing started..."

Pattern 5: Building Complex PATH Structures

# Add directories conditionally
if test -d /opt/local/bin
    fish_add_path /opt/local/bin
end

if test -d ~/.cargo/bin
    fish_add_path ~/.cargo/bin
end

# Remove obsolete paths
if set -q OLD_TOOL_PATH
    set -gx PATH (string match -v "$OLD_TOOL_PATH/*" $PATH)
end

Troubleshooting Common Issues

Variable not visible in child process: Ensure you used -x or --export when setting the variable. Use set --show VARNAME to verify export status.

Universal variable not persisting: Check that the Fish variable database exists and is writable at ~/.local/share/fish/fish_variables. If you're using multiple Fish versions, the database format may be incompatible.

PATH modifications not taking effect: If you set PATH as a string (colon-delimited) rather than a list, Fish won't iterate it correctly. Always use fish_add_path or set PATH as a proper list.

Index errors with list variables: Remember Fish uses 1-indexing. $list[0] will produce an error. Use $list[1] for the first element.

Variable leaking between scripts: If you source a script with . or source, its global variables persist in your session. Use local variables (-l) inside sourced scripts to prevent this, or wrap the sourced code in a begin...end block.

Conclusion

Fish shell's approach to environment variables is refreshingly consistent and well-designed. By treating all variables as lists, offering clear scope semantics, and providing the innovative universal variable mechanism, Fish eliminates many of the quirks that make variable handling painful in POSIX shells. The set command's flags give you precise control over scope and export behavior, while fish_add_path makes PATH management safe and predictable. Whether you're writing a one-off script, configuring your interactive shell, or building a complex development environment, understanding these variable mechanics will help you write cleaner, more maintainable Fish code. The key is to be explicit about scope, leverage universal variables for persistent configuration, and embrace Fish's list-based nature rather than fighting against it with string-based workarounds.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles