skip to content

A colleague using fish finds that one directory ends up in their PATH twice in every new terminal, and that a variable they deleted from `~/.config/fish/config.fish` is still set in fresh shells. What are fish's universal variables, and how should fish configuration be laid out so this does not happen?

level: seniorimportance: nice to knowfreq 15%

answer

  1. some fish config is state, not a file you edit
  2. set -U writes to disk immediately
  3. config.fish runs on every single start
  4. erase it, do not just delete the line
  5. fish_add_path is idempotent

basics

~20 s

A universal variable, set with set -U, is stored on disk in ~/.config/fish/fish_variables and shared by all of that user's fish sessions. Running set -U from config.fish re-applies it on every start, and deleting the line never erases what was already persisted.

solid answer

~40 s

fish variables have scopes: local, global (`set -g`) and universal (`set -U`). A universal variable is persisted to `~/.config/fish/fish_variables` and immediately visible in every running fish session for that user, so setting one is a **one-time act**, not something you repeat at startup. Both symptoms follow from putting `set -U` in `config.fish`. The duplicate PATH entry is `set -U fish_user_paths /opt/tool/bin $fish_user_paths` running on every launch and prepending to a value that already contains the directory. The undead variable is the persisted copy in `fish_variables`, which removing a config line cannot touch — you have to erase it explicitly with `set -e`. The fix is to keep `config.fish` idempotent: use `fish_add_path` for PATH, `set -gx` for session variables, guard interactive-only setup with `status is-interactive`, and put functions in `~/.config/fish/functions/`.

code

bash · 14 lines
bash
# fish shell -- corrected config.fish
# Idempotent: safe to run at every shell start.
fish_add_path /opt/tool/bin

set -gx EDITOR nvim

if status is-interactive
    abbr -a gco git checkout
end

# Run ONCE from the prompt, never from config.fish:
#   set -Ux LESS '-R'
# Undo a persisted universal variable:
#   set -eU LESS

go deeper

for a junior

Know the three scope flags on fish's set: -l local, -g global to this session, -U universal and saved to disk. Know that -x on top of any of them exports to child processes.

for a middle

Explain why set -U inside config.fish compounds: the file runs every launch, the variable is already persisted, so a prepend grows the list each time. Know that set -e is what actually removes one.

for a senior

Diagnose from the symptom and prescribe the layout: fish_add_path for PATH, set -gx for session variables, autoloaded function files, and a status is-interactive guard around anything that prints or binds keys.

for a principal

Own the reproducibility question. Part of fish's configuration is state the shell writes, so decide as a team whether fish_variables is committed as generated state or re-derived by an idempotent setup script, and document which.

## fish's variable scopes fish's `set` builtin takes a scope flag, and the scope determines both lifetime and reach: - `set -l NAME value` — **local**, visible only inside the current block or function. - `set -g NAME value` — **global**, visible everywhere in *this* fish process, and gone when the process exits. - `set -U NAME value` — **universal**, shared by every fish session the user is currently running and persisted across restarts. The `-x` flag is orthogonal: it exports the variable to child processes, so `set -gx` is the everyday "environment variable for this session" and `set -Ux` is "environment variable for me, on this machine, forever". Universal variables are the feature that distinguishes fish's configuration model. They are how `fish_config` works: change your colour scheme in that interface and it writes `fish_color_*` universal variables, and every terminal you already have open changes colour immediately, with no reload and no `source`. That is genuinely pleasant, and it is also exactly what produces the two symptoms in the question. ## Where they live fish serialises universal variables to `~/.config/fish/fish_variables`. That file is machine state written by fish, not a dotfile you hand-edit; fish rewrites it whenever a universal variable changes and notifies other running sessions. ## Why PATH gains a duplicate every launch The classic broken line looks like this: ```fish # in config.fish -- WRONG set -U fish_user_paths /opt/tool/bin $fish_user_paths ``` `config.fish` runs for every new fish session. `fish_user_paths` is universal, so it already holds `/opt/tool/bin` from the last time the line ran. The line prepends it again, and the new, longer value is persisted — so the list grows by one entry per terminal you open, forever. The user sees a PATH that is subtly wrong, then absurdly long, and cannot find the cause because the config file only mentions the directory once. The underlying rule is simple: **anything you set universally must not be set from a file that runs at every startup.** Universal variables are set once, interactively, and then they are on disk. ## Why deleting the line did not unset it Because `config.fish` is not the source of truth for a universal variable — `fish_variables` is. Removing the assignment stops fish from re-applying the value, but the persisted copy is still loaded at every start. To actually remove it you erase it: ```fish set -e NAME # erase in the innermost scope that has it set -eU NAME # erase the universal one specifically ``` This catches people out precisely because every other shell they have used stores configuration only in files they edit. In fish, part of the configuration is state the shell owns. ## How to lay the configuration out - **PATH:** use `fish_add_path /opt/tool/bin` (fish 3.2 and later). It is idempotent — adding a directory already present is a no-op — and it manages `fish_user_paths` for you. That single helper removes the whole class of bug above. - **Session variables:** `set -gx NAME value` in `config.fish`. Global scope means it is recomputed each session rather than persisted, which is what you want for anything derived from the environment. - **Universal variables:** set them once from the prompt, or from `fish_config`. If you want them reproducible, treat `fish_variables` as generated state and re-derive it from a setup script you run deliberately — not from `config.fish`. - **Functions:** one function per file in `~/.config/fish/functions/<name>.fish`, autoloaded on first use rather than parsed at startup. `funcsave` writes a function you defined interactively into that directory. This keeps `config.fish` short, which is itself a shell-startup-latency win. - **Interactive-only setup:** `config.fish` runs for non-interactive fish invocations too, so wrap prompts, greetings, key bindings and anything that prints output: ```fish if status is-interactive abbr -a gco git checkout end ``` ## The dotfiles consequence This is the part worth raising unprompted, because it is the operational cost of the model. A dotfiles repository that tracks only `config.fish` does **not** capture your colour scheme, your `fish_config` choices, or any `set -U` you have made — those are in `fish_variables`, and the prompt you picked in the web interface is saved as a function file (`~/.config/fish/functions/fish_prompt.fish`). Restore such a repo on a new machine and you get a shell that is subtly not the one you left. Either commit those artefacts too, accepting that `fish_variables` is a generated file that will churn, or keep a small idempotent setup script that re-issues the universal settings, and treat `config.fish` as the only hand-written piece. Deciding which of the two you are doing — and writing it down for the next person — is the senior-level part of the answer.

  • What is the practical difference between set -gx and set -Ux for an environment variable?
    `set -gx` lives only in the current fish process, so it is recomputed from `config.fish` at every start and never persists — the right choice for anything derived from the environment. `set -Ux` writes to `fish_variables` and applies to every session immediately and after reboot, so it is a one-time act you should not repeat at startup.
  • Why does fish recommend putting functions in ~/.config/fish/functions/ rather than defining them in config.fish?
    Files in that directory are autoloaded lazily: fish reads `<name>.fish` the first time the function is called, so a hundred functions cost nothing at startup. Definitions in `config.fish` are parsed on every launch instead. It also keeps one function per file, which is easier to edit and version.
  • You clone your dotfiles onto a new machine and fish looks wrong despite config.fish being identical. What did you miss?
    The state fish owns rather than you: universal variables in `fish_variables` — including every `fish_color_*` set through `fish_config` — and the prompt, which `fish_config` saves as `~/.config/fish/functions/fish_prompt.fish`. Either track those files too or keep a setup script that re-issues the universal settings deliberately.
  • Why does config.fish need a `status is-interactive` guard at all?
    Because fish reads `config.fish` for non-interactive invocations too. Anything that prints a greeting, sets key bindings or configures a prompt is at best wasted work there, and at worst it corrupts the output of a command someone is capturing. Guard the interactive half and leave PATH and exports outside the guard.

saying these in an interview costs you the question

  • Thinks deleting a line from config.fish unsets a universal variable
  • Sets universal variables from config.fish because it looks like exporting
  • Believes fish_variables is a dotfile meant to be hand-edited
  • Says universal only means persisted, missing the live cross-session sharing
  • Puts every function definition in config.fish and wonders why startup is slow

context