← Back to DevBytes

Fish Scripting: Startup Files Complete Guide

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:

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:

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:

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:

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:

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:

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:

Best Practices Summary

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.

🚀 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