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:
- Tool configuration — Compilers, package managers, and build tools read variables like
CC,GOPATH, orNODE_ENVto determine behavior. - Runtime context — Servers and daemons use
PORT,DATABASE_URL, andLOG_LEVELto configure themselves without hard-coded values. - Cross-session persistence — Fish's universal variables allow settings to persist across all Fish sessions, even in different terminal windows.
- Shell customization — Your prompt, color themes, and aliases often depend on variable-driven configuration.
- Script portability — Well-structured scripts read environment variables rather than relying on hard-coded paths or assumptions.
Variable Scopes in Fish
Fish offers four distinct scopes for variables. Understanding these is essential before writing any script:
- Local (
-lor--local) — Visible only inside the current block or function. These are created fresh each time the block runs and destroyed when it exits. - Global (
-gor--global) — Visible everywhere in the current Fish session but not inherited by other sessions. - Universal (
-Uor--universal) — Persist across all Fish sessions on the machine, stored in a database file. Changes in one terminal window take effect in all others. - Exported (
-xor--export) — Marked for export to child processes. Can be combined with any of the above scopes.
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:
- Cross-session configuration like
EDITORorBROWSER - Tool paths that should persist (
GOPATH,JAVA_HOME) - User preferences that don't need to be re-set in every shell
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:
$status— Exit code of the last command (0 for success)$argv— Arguments passed to the current function or script$fish_pid— Process ID of the current Fish session$CMD_DURATION— How long the last command took (in milliseconds)$fish_bind_mode— Current key binding mode (default, insert, visual, etc.)$fish_term24bit— Whether the terminal supports true color (24-bit)$fish_term256— Whether the terminal supports 256 colors$SHLVL— Depth of nested shell sessions$HOME— Current user's home directory$USER— Current username
# 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:
- Universal variables — Use
set -Ufor automatic persistence in the Fish variable database. - Config files — Define variables in
~/.config/fish/config.fishfor 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
- Use
fish_add_pathfor PATH manipulation — It handles deduplication, respects universal scope, and is self-documenting. Avoid rawset PATH ...unless you have a specific reason. - Prefer universal variables for user preferences — Things like
EDITOR,BROWSER, andJAVA_HOMEshould be universal so they're consistent across all terminal windows. - Keep secrets in universal variables, not in config files — Universal variables live in the Fish database, not in your dotfiles repository. This prevents accidental secret exposure while still providing persistence.
- Be explicit about scope — Always specify
-g,-l, or-Urather than relying on Fish's default scoping rules. This makes your intent clear and prevents bugs when code moves between scripts and functions. - Use
set -qfor existence checks — This is the idiomatic Fish way to test whether a variable is defined, rather than comparing to empty strings. - Remember Fish lists are 1-indexed — Unlike most programming languages,
$list[1]is the first element. Using index 0 will produce an error. - Export only what's needed — Don't blindly export all variables with
-x. Only mark variables for export when child processes genuinely need them. - Document universal variables — Keep a comment in
config.fishlisting which universal variables you've set and why, even though their values live in the Fish database. - Test variable operations in interactive sessions first — Universal variable changes take effect immediately across all sessions. Test carefully before setting universal variables that could break other shells.
- Use
stringbuiltin for variable manipulation — Fish'sstringcommand is powerful and fast. Use it for splitting, joining, filtering, and transforming variable contents rather than external tools likeawkorsed.
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.