Fish Universal Variables: Persistent State, Scope Precedence, and Exports
Operate Fish universal variables safely across shells and restarts, diagnose scope precedence, and separate persisted Fish state from child-process environments.
Fish variables have two independent properties that are easy to conflate: scope and export status. A variable can be local, global, function-scoped, or universal, and it can separately be exported to child processes. Universal scope adds persistence and sharing among a user’s Fish instances, but it does not make the value an operating-system environment variable by itself.
That distinction explains many “I set EDITOR, but another command or shell does not see it” incidents. Fish imports inherited environment variables as global exported variables. A universal value with the same name can exist at the same time, yet the narrower global scope wins. The correct debugging workflow is to inspect all scopes, decide which process or Fish instance should own the setting, then erase or override the relevant layer explicitly.
Treat scope and export as separate axes
set -l creates a block-local value, set -f creates a function-scoped value, set -g creates a global value for the current Fish process, and set -U creates a universal value shared with the user’s other Fish processes and persisted across shell restarts. Export flags such as -x control whether an external child process receives the value in its environment. For example, set -Ux EDITOR vim is both universal and exported; set -U EDITOR vim persists in Fish but is not automatically visible to external commands.
set -U workspace_root ~/src
set -gx EDITOR vim
set -l one_command_mode check
set --show workspace_root EDITOR one_command_mode
set --show is useful because it prints each matching definition, its scope, its values, and whether it is exported. Use it before changing configuration. A simple echo $EDITOR shows the value Fish resolves, but not whether a global definition is hiding a universal one or whether the value will reach a child process.
Scope is not inherited by child processes. A function can read visible values according to Fish’s scope rules, while an external program sees only the process environment assembled from exported values. A second Fish process receives exported environment from its parent and can also share universal variables through Fish’s universal-variable mechanism. These are different data paths: environment inheritance is a process-start snapshot, while universal scope is Fish-managed shared persistent state.
Diagnose why an exported universal setting appears ignored
Suppose set -Ux EDITOR nvim succeeds, but typing echo $EDITOR still prints nano, or a child command sees a value from a login manager. Fish resolves a local definition before a global definition and a global definition before a universal definition. If EDITOR was already present in the environment when Fish started, Fish imported that name as a global exported variable. That global shadows the universal value.
set --show EDITOR
# If the unwanted definition is the imported/current global value:
set -e --global EDITOR
# Then inspect what remains visible:
set --show EDITOR
Erasing the global definition from the current shell allows the universal value to become visible if one exists. It does not rewrite the environment of Fish’s parent process, and it does not change already-running child processes. A new Fish process launched from the same parent may import the same original global value again. If a login manager, terminal, service, or administrator supplies the value, fix that upstream source or deliberately set the intended global value in Fish configuration.
If the intended rule is “use this editor in every Fish session regardless of the inherited environment,” a statement such as set -gx EDITOR vim in config.fish may be more explicit than a universal variable. If the setting should persist only within Fish and should not cross into external commands, use set -U without -x. Choose based on the boundary the consumer needs, not on which flag looks more permanent.
Use universal state sparingly and make it inspectable
Universal variables are convenient for interactive preferences that should follow the user across Fish sessions: a preferred editor, a prompt preference, or a small directory list. Set them once and avoid repeating assignments in every startup file. Duplicating the same value in universal storage and config.fish creates multiple sources of truth and makes scope precedence harder to diagnose.
Keep machine- or project-specific settings out of a user’s universal state unless sharing them across every Fish session is intentional. A universal variable is not scoped to one repository or terminal tab. If a project needs a tool path or build mode, set it in the project wrapper, a task environment, or an explicit command invocation rather than changing the user’s global interactive environment.
To remove a universal definition explicitly, use set -e --universal NAME. To remove both global and universal definitions, specify those scopes, for example set -e --global --universal NAME. This does not erase a local or function-scoped definition. Avoid a broad erase command until set --show NAME has confirmed which definitions exist. Erasing one scope does not necessarily erase another definition of the same name.
Universal variables can be updated in one Fish process and become available to the user’s other Fish instances. That sharing is useful for preferences, but it is not a transaction protocol between shells. Do not use a universal variable as a lock, a counter that must be incremented exactly once, or a cross-process work queue. Concurrent writers and timing-sensitive consumers need an actual IPC or database mechanism with the required synchronization and durability contract.
Understand the special case of PATH
Fish treats path variables as lists internally and joins them with colons when exporting them. Variables whose names end in PATH are automatically treated as path variables by default. This means PATH behaves like a list in Fish even though an external process receives the conventional colon-delimited environment string.
set --show PATH
set -gx PATH $PATH /opt/acme/bin
command -v acme-tool
The assignment affects the current Fish process and its future children, but it does not persist across a new Fish session unless it is written to universal state or configuration. When adding paths interactively, consider the Fish-provided fish_add_path helper for duplicate-aware updates; test its persistence options on the installed Fish release and avoid appending the same directory from both a startup file and universal configuration.
A path list is not a general list serialization format. Colons delimit its exported representation, so a path element containing a colon cannot round-trip as one environment element. For non-path lists, Fish uses its own list values internally; when exported as one environment variable, elements are serialized with spaces. If a child program needs structured multi-value data, define a format or pass repeated command arguments rather than assuming an environment variable preserves Fish’s list boundaries.
Prefer explicit scope in reusable Fish code
Fish has useful default scope rules, but reusable functions are easier to audit when scratch variables declare their intended scope. An unqualified assignment updates the narrowest existing definition with that name. If no value exists, it normally creates a function-scoped variable inside a function, while set -l is local to the innermost block. Reusing an ambient name can therefore mutate a global setting unexpectedly.
function with_temporary_mode
set -l saved_mode $app_mode
set -l app_mode diagnostic
run-diagnostic
# This local value disappears at the end of its block/function.
end
The example deliberately declares local variables, but a function that needs to update caller-visible state should document that contract and use an explicit target scope. Do not rely on an accidental same-name variable from a caller. In particular, exported status can persist when an existing variable is updated without an explicit -x or -u; the rules for scope and export both reuse prior definitions. set --show reveals the effective configuration during debugging.
Command-scoped assignments can be useful for one subprocess. Fish supports NAME=value command, and set -lx NAME value within a block can express a temporary local exported value. Prefer this over changing a universal or global preference just to launch one tool with a different environment. Verify the target command’s environment with a harmless diagnostic, and do not print sensitive values into logs.
Validate configuration across the actual launch chain
Test from the same path that users will use: launch Fish from the terminal, open another Fish instance, start a child command, and launch a fresh login or non-interactive shell if those matter. Record set --show NAME within each shell and use a test child that prints only the specific non-secret variable under test. An interactive shell’s display does not prove what a service, GUI application, SSH session, or script will inherit.
When a value differs, inspect the parent environment and Fish scopes separately. Check the terminal or login manager configuration, Fish’s config.fish, any plugin or prompt initialization, and universal state. Change one owner at a time, start a new shell, and repeat. If a universal value is meant to be exported, test both the Fish-level resolved value and the child process environment. If it should stay Fish-only, confirm that env does not receive it.
The operational rule is straightforward: use universal scope for persistent Fish preferences, global scope for current-process configuration, local or function scope for temporary state, and export only when a child process needs the value. Inspect before erasing, and do not confuse cross-Fish persistence with a general operating-system environment or synchronization primitive.
Related:
- Environment Variables, Exports, and Subshell Boundaries
- How to Build a Cross-Shell Dotfiles Repository
Sources: