What Are Fish Startup Files?
Fish startup files are scripts that the Fish shell automatically evaluates when a new session begins. They define your interactive environment—setting variables, declaring aliases, loading plugins, customizing the prompt, and running any initialization logic you need. Unlike Bash or Zsh, Fish uses a dramatically simplified startup sequence with a single main configuration file and an optional modular directory, making it one of the most predictable shells to configure.
Understanding startup files is essential because every customization you make—from a simple alias to a complex prompt theme—depends on being loaded at the right time in the right order. Misplacing a definition can cause missing commands, broken completions, or subtle bugs that only appear in certain contexts.
The Fish Config Directory Layout
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Fish follows the XDG Base Directory specification. All user-specific configuration lives under ~/.config/fish/. Here is the canonical structure:
~/.config/fish/
├── config.fish # Main startup script (runs for interactive sessions)
├── conf.d/ # Modular config snippets (loaded before config.fish)
│ ├── 00-env.fish
│ ├── aliases.fish
│ └── prompt.fish
├── functions/ # Autoloaded function definitions
│ └── my_custom_func.fish
└── completions/ # Custom tab-completion definitions
└── my_tool.fish
The shell also reads system-wide files when present. The exact locations vary by OS and installation method, but typically include:
/etc/fish/config.fish # System-wide config (all users)
/usr/share/fish/config.fish # Distribution defaults (often symlinked)
Fish exposes these paths through built-in variables so you never need to hardcode them:
echo $__fish_config_dir # ~/.config/fish (user config root)
echo $__fish_sysconf_dir # /etc/fish (system-wide root)
echo $__fish_data_dir # /usr/share/fish (distro data)
echo $__fish_user_data_dir # ~/.local/share/fish (universal variables)
Loading Order (And Why It Matters)
When you open a new terminal, Fish loads files in this exact sequence:
- 1. System-wide conf.d —
/etc/fish/conf.d/*.fish(or disto equivalent), sorted alphabetically - 2. System-wide config.fish —
/etc/fish/config.fish - 3. User conf.d —
~/.config/fish/conf.d/*.fish, sorted alphabetically - 4. User config.fish —
~/.config/fish/config.fish
This order means that files in conf.d always run before config.fish. If you need to set a variable that other initializations depend on, placing it in an alphabetically early conf.d file (like 00-env.fish) ensures it's available everywhere else. Conversely, config.fish runs last, so it can override or react to anything set earlier.
All of these files only execute for interactive shells. Non-interactive shells (like fish -c 'echo hi' or scripts run with fish script.fish) skip the startup sequence entirely—they load only the universal variables and the function autoloader. This keeps script execution fast and predictable.
Login Shells vs. Interactive Shells
Fish does not automatically source a separate file for login shells. However, you can detect a login shell inside config.fish using the status command and conditionally run login-specific logic:
# Inside ~/.config/fish/config.fish
if status is-login
# Set environment variables that should propagate to all child processes
set -x PATH $HOME/bin $PATH
set -x EDITOR nvim
# Run login-only tasks
fish_add_path --global --append $HOME/.local/bin
end
The status is-login check returns true when Fish is launched with --login or when it is the initial shell in a terminal session (determined by the parent process). This is the clean way to separate login-environment setup from per-shell customizations.
config.fish — The Main Configuration File
config.fish is your primary startup script. It runs for every interactive session after all conf.d snippets have been processed. Use it for:
- Setting session-specific variables (not meant for child processes)
- Defining aliases and abbreviations
- Customizing the prompt
- Initializing plugins or themes
- Printing welcome messages or system info
- Adding paths that should only exist interactively
Basic config.fish Example
# ~/.config/fish/config.fish
# ---- Greeting (runs every interactive session) ----
function fish_greeting
echo "Welcome back, "(whoami)"!"
echo "Fish version: "$FISH_VERSION
echo "Current directory: "(pwd)
end
# ---- Session-only variables (not exported) ----
set BROWSER firefox
set FZF_DEFAULT_COMMAND 'fd --type f --hidden'
# ---- Exported environment variables (visible to child processes) ----
set -x EDITOR nvim
set -x VISUAL $EDITOR
set -x PAGER less
set -x MANPAGER 'less -R'
# ---- Path additions (interactive-only) ----
fish_add_path $HOME/.cargo/bin
fish_add_path $HOME/.local/bin
fish_add_path $HOME/go/bin
# ---- Aliases (use functions for anything complex) ----
alias ll 'eza -la --icons'
alias lt 'eza --tree --level=2 --icons'
alias cat 'bat --paging=never'
# ---- Abbreviations (expand inline as you type) ----
abbr --add gco git checkout
abbr --add gst git status
abbr --add gcm 'git commit -m'
# ---- Source a local-only file if it exists (gitignored) ----
if test -f ~/.config/fish/local.fish
source ~/.config/fish/local.fish
end
Conditional Loading Based on Hostname
# ~/.config/fish/config.fish
switch (hostname)
case 'work-laptop'
set -x AWS_PROFILE production
set -x DATABASE_URL (cat ~/.secrets/db_url_work)
fish_add_path /opt/company/bin
case 'home-desktop'
set -x AWS_PROFILE personal
fish_add_path /usr/local/games
case '*'
echo "Generic host config loaded"
end
conf.d — Modular Configuration Snippets
The conf.d/ directory is Fish's answer to organized, composable configuration. Every .fish file in this directory is automatically sourced before config.fish, in alphabetical order. This design allows package managers, plugin frameworks, and you to drop in configuration pieces without ever editing a central file.
Use conf.d for:
- Environment variables that must be set early
- Plugin initialization scripts
- Package-manager-installed completions or key bindings
- Separation of concerns (one file per tool or domain)
Organized conf.d Example
# ~/.config/fish/conf.d/00-env.fish
# Loaded FIRST — foundational environment variables
set -x LANG en_US.UTF-8
set -x LC_ALL en_US.UTF-8
set -x XDG_CONFIG_HOME $HOME/.config
set -x XDG_CACHE_HOME $HOME/.cache
set -x XDG_DATA_HOME $HOME/.local/share
set -x CARGO_HOME $HOME/.cargo
set -x GOPATH $HOME/go
# ~/.config/fish/conf.d/10-colors.fish
# Loaded SECOND — terminal and theming
set -x TERM xterm-256color
set -g fish_color_autosuggestion brblack
set -g fish_color_command blue
set -g fish_color_param cyan
set -g fish_color_error red --bold
# ~/.config/fish/conf.d/20-ssh-agent.fish
# Loaded THIRD — SSH agent setup
if not pgrep -u (whoami) ssh-agent > /dev/null
eval (ssh-agent -c)
set -Ux SSH_AGENT_PID $SSH_AGENT_PID
set -Ux SSH_AUTH_SOCK $SSH_AUTH_SOCK
end
# ~/.config/fish/conf.d/30-direnv.fish
# Loaded FOURTH — direnv hook (if installed)
if type -q direnv
direnv hook fish | source
end
# ~/.config/fish/conf.d/90-key-bindings.fish
# Loaded LAST in conf.d — custom key bindings
function fish_user_key_bindings
bind \cr 'echo "Custom binding: reloaded!"'
bind \cf 'fzf-fish-widget' # requires fzf.fish plugin
end
Naming Convention for conf.d Files
The numeric prefix convention (00-, 10-, 20-) gives you explicit control over load order. Files without numbers load alphabetically by filename, which can be unpredictable. Adopt a numbering scheme:
00-to09-: Environment variables and foundational setup10-to19-: Colors, terminal settings, display options20-to39-: External tool hooks (ssh-agent, direnv, starship)40-to59-: Plugin and framework initialization60-to79-: Custom functions and completions80-to99-: Key bindings, prompt finalization, overrides
This convention prevents the "works-on-my-machine" chaos that happens when load order is accidental.
Universal Variables — Persistent Storage Across Sessions
Fish has a unique concept called universal variables. These are stored on disk (in ~/.local/share/fish/) and persist across all Fish sessions—even across different terminal windows. They are loaded before any startup file runs, so they're always available.
Set a universal variable with set -U:
# Set once, available forever in all fish sessions
set -U FAVORITE_COLOR blue
set -Ux SECRET_TOKEN "abc123" # -Ux makes it exported too
# List all universal variables
set --universal
# Or just:
set -U
Universal variables are ideal for:
- User preferences that rarely change (color themes, default tools)
- Secrets or tokens set once via an external workflow
- Cross-session state (like SSH agent socket paths)
Important: Universal variables are NOT set in startup files. If you put set -U in config.fish, it will overwrite the stored value every time you open a shell, defeating the purpose. Set universal variables interactively or in one-shot setup scripts.
# DO THIS: set interactively once
> set -U FAVORITE_EDITOR nvim
# NOT THIS: don't put universal sets in config.fish
# ~/.config/fish/config.fish
# set -U FAVORITE_EDITOR nvim # BAD — overwrites every session
Functions — Autoloading vs. Manual Sourcing
Fish automatically discovers functions placed in specific directories. You never need to manually source them in config.fish:
# Create a function file — it's autoloaded on first use
# ~/.config/fish/functions/my_backup.fish
function my_backup
set source_dir $argv[1]
set target_dir $argv[2]
rsync -avh --progress $source_dir $target_dir
echo "Backup complete: "(date)
end
Place the function in any of these directories and Fish will find it:
~/.config/fish/functions/(user functions)/etc/fish/functions/(system-wide)/usr/share/fish/functions/(distro defaults)
The autoloader works by scanning these directories at startup. On first invocation of a function name, Fish reads the corresponding file and defines the function. This lazy-loading keeps shell startup fast even with hundreds of custom functions.
Forcing Immediate Function Definition
If you need a function to be available during startup (because config.fish or another snippet calls it), use --on-demand flag or source it explicitly:
# In config.fish, explicitly source a function if needed immediately
source ~/.config/fish/functions/my_helper.fish
my_helper "arg"
Practical Startup File Recipes
Recipe 1: Starship Prompt Integration
# ~/.config/fish/conf.d/40-starship.fish
if type -q starship
starship init fish | source
else
# Fallback prompt if starship isn't installed
function fish_prompt
echo -n (set_color blue)(prompt_pwd)(set_color normal)' > '
end
end
Recipe 2: Automatic Virtual Environment Activation
# ~/.config/fish/conf.d/50-venv.fish
function auto_venv --on-variable PWD --description 'Auto-activate Python venv'
if test -f ./venv/bin/activate.fish
source ./venv/bin/activate.fish
echo "Activated venv in "(pwd)
else if test -n "$VIRTUAL_ENV"
# Deactivate if we left the venv directory
if not string match --quiet "$VIRTUAL_ENV*" (pwd)
deactivate 2>/dev/null
end
end
end
Recipe 3: Loading Secrets from a Separate File
# ~/.config/fish/config.fish (bottom of file)
# Load secrets from a gitignored file
set secrets_file ~/.config/fish/secrets.fish
if test -f $secrets_file
source $secrets_file
echo "Secrets loaded from $secrets_file"
else
echo "Warning: No secrets file found at $secrets_file"
end
# ~/.config/fish/secrets.fish (gitignored, never committed!)
set -x GITHUB_TOKEN "ghp_xxxxxxxxxxxx"
set -x DOCKER_PASSWORD "secret123"
set -x DATABASE_URL "postgres://user:pass@localhost/db"
Recipe 4: Profile-Guided Startup Timing
# ~/.config/fish/config.fish — measure what slows down your shell
if status is-interactive
set startup_start (date +%s%N)
# Your normal config here...
set startup_end (date +%s%N)
set startup_duration (math "($startup_end - $startup_start) / 1000000")
printf "Startup time: %d ms\n" $startup_duration
end
Debugging Startup Issues
When something goes wrong in startup files, Fish provides built-in tools to diagnose problems:
# Start fish with no startup files at all
fish --no-config
# Start fish and trace what gets executed
fish --debug-level=3 --debug-output=/tmp/fish_debug.log
# Check which files fish thinks it will load
fish --print-rusage-self 2>&1 | head
# Source a specific file in isolation to test it
fish -c 'source ~/.config/fish/conf.d/20-ssh-agent.fish; echo $SSH_AUTH_SOCK'
Common startup errors include:
- Syntax errors in config.fish: Fish prints the file, line number, and a description. Fix the syntax and restart.
- Missing commands: If a function or alias isn't available, check that its definition file is in the right directory and has the correct name.
- Order-dependent failures: A variable used before it's set. Move its definition to an earlier conf.d file or to the top of config.fish.
- Universal variable wipe: Accidentally setting
set -Uin config.fish overwrites stored values. Remove the line and reset the universal interactively.
Best Practices Summary
- Keep config.fish fast. Every millisecond you add delays every new terminal. Profile occasionally and move heavy initialization to lazy functions or on-demand triggers.
- Use conf.d for modularity. One file per tool or domain, with numeric prefixes for explicit ordering. This makes your configuration portable and auditable.
- Never set universal variables in startup files. Use
set -Uonly interactively. In startup files, useset -g(global, session-scoped) orset -x(exported). - Separate login-only logic. Use
status is-loginto guard environment variable exports and path additions that should happen once per login session. - Use
fish_add_pathinstead of manually mutating PATH. It deduplicates entries and respects the correct variable scope. - Prefer functions over aliases for anything nontrivial. Functions autoload, support arguments naturally, and can include full logic. Aliases are just simple shorthand.
- Guard optional integrations. Always check if a command exists (
type -q) before sourcing its hook, so missing tools don't break your shell. - Use
$__fish_config_dirand friends. Hardcoding~/.config/fishworks but the built-in variables future-proof your scripts if paths change. - Commit your config, not your secrets. Use a separate sourced file (gitignored) for tokens, keys, and credentials.
- Test with
fish --no-config. When debugging, strip all startup files to isolate whether the problem is in your config or the shell itself.
Complete Example: A Production-Ready fish Configuration
Below is a realistic, complete startup setup suitable for a developer workstation. It demonstrates all the principles covered above.
# ~/.config/fish/conf.d/00-env.fish
# Foundation — environment variables needed by everything else
set -x LANG en_US.UTF-8
set -x LC_ALL en_US.UTF-8
set -x EDITOR nvim
set -x VISUAL $EDITOR
set -x PAGER less
set -x BROWSER firefox
# XDG paths
set -x XDG_CONFIG_HOME $HOME/.config
set -x XDG_CACHE_HOME $HOME/.cache
set -x XDG_DATA_HOME $HOME/.local/share
# Language runtimes
set -x CARGO_HOME $HOME/.cargo
set -x GOPATH $HOME/go
set -x NVM_DIR $HOME/.nvm
# Path additions (exported so scripts see them)
fish_add_path --global $HOME/.cargo/bin
fish_add_path --global $HOME/.local/bin
fish_add_path --global $HOME/go/bin
fish_add_path --global /usr/local/bin
# ~/.config/fish/conf.d/10-colors.fish
# Terminal colors and fish-specific theming
set -g fish_color_normal white
set -g fish_color_command blue --bold
set -g fish_color_param cyan
set -g fish_color_comment brblack --italics
set -g fish_color_error red --bold
set -g fish_color_escape bryellow
set -g fish_color_operator magenta
set -g fish_color_quote green
set -g fish_color_autosuggestion brblack --dim
set -g fish_color_selection --background=brblue
set -g fish_color_cwd blue
set -g fish_color_cwd_root red
# ~/.config/fish/conf.d/20-tools.fish
# Hooks for development tools (guarded with type -q)
if type -q direnv
direnv hook fish | source
end
if type -q zoxide
zoxide init fish | source
end
if type -q fzf
fzf --fish | source
end
if type -q starship
starship init fish | source
end
# ~/.config/fish/config.fish
# Main configuration — runs after all conf.d files
# ---- Login-only block ----
if status is-login
# Set up session-wide environment
set -x SSH_AUTH_SOCK (gpgconf --list-dirs agent-ssh-socket 2>/dev/null)
# Verify GPG agent is running
if not pgrep -u (whoami) gpg-agent > /dev/null
gpg-agent --daemon --enable-ssh-support > /dev/null 2>&1
end
# Platform-specific paths
switch (uname)
case Linux
fish_add_path --global /usr/local/games
case Darwin
fish_add_path --global /opt/homebrew/bin
eval (/opt/homebrew/bin/brew shellenv)
end
end
# ---- Session-only variables (not exported) ----
set FZF_DEFAULT_COMMAND 'fd --type f --hidden --exclude .git'
set BAT_THEME 'Catppuccin-mocha'
# ---- Abbreviations ----
abbr --add g git
abbr --add ga git add
abbr --add gco git checkout
abbr --add gst git status
abbr --add gcm 'git commit -m'
abbr --add gp git push
abbr --add gl git pull
abbr --add gd git diff
abbr --add gds 'git diff --staged'
# ---- Custom prompt fallback (if starship isn't installed) ----
if not type -q starship
function fish_prompt
set last_status $status
set_color cyan
echo -n (prompt_pwd)
set_color normal
if test $last_status -ne 0
set_color red
echo -n " [exit: $last_status]"
set_color normal
end
echo -n ' » '
end
end
# ---- Welcome message ----
function fish_greeting
set os_name (uname -s)
set kernel (uname -r)
set uptime_str (uptime | string replace -r '.*up (.*?),.*' '$1')
echo ""
echo "🟢 Fish $FISH_VERSION | $os_name $kernel"
echo "⏱ Uptime: $uptime_str"
echo "📂 "(pwd)
echo ""
end
# ---- Source local secrets (gitignored) ----
if test -f $__fish_config_dir/secrets.fish
source $__fish_config_dir/secrets.fish
end
# ---- Startup time measurement (remove when stable) ----
if status is-interactive
set -g fish_startup_time (math "(
date +%s%N - $fish_startup_time_start
) / 1000000" 2>/dev/null)
if test -n "$fish_startup_time"
printf "⚡ Startup: %d ms\n" $fish_startup_time
end
end
# ~/.config/fish/secrets.fish
# NEVER COMMIT THIS FILE — add to .gitignore
set -x GITHUB_TOKEN "ghp_1234567890abcdef"
set -x AWS_ACCESS_KEY_ID "AKIAIOSFODNN7EXAMPLE"
set -x AWS_SECRET_ACCESS_KEY "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
set -x DOCKER_PASSWORD "supersecret"
set -x NPM_TOKEN "npm_abcdef123456"
Conclusion
Fish startup files give you precise control over your interactive environment while remaining refreshingly simple compared to other shells. The combination of a single config.fish, an alphabetically-ordered conf.d/ directory, and lazy autoloading for functions creates a configuration system that scales from a handful of aliases to hundreds of modular plugins without becoming brittle. By understanding the loading order, using the right variable scopes, guarding optional integrations, and keeping startup fast, you can build a shell environment that is both powerful and maintainable. The patterns shown here—numeric-prefixed conf.d files, login-only blocks, gitignored secrets files, and profile-guided optimization—form a solid foundation for any Fish user, from casual terminal users to full-time developers.