Fish Shell Functions & Custom Commands Guide

How to create, edit, save, and autoload functions in Fish Shell. Covers event handlers, argument parsing, scope, and practical examples.

Fish Shell Functions & Custom Commands Guide

Functions in Fish are how you build reusable commands. If you’ve used Bash functions before, the idea is the same, but Fish’s implementation is cleaner — no curly braces, explicit argument handling through $argv, and a lazy-loading system that keeps startup fast.

I use functions for everything from quick shortcuts to project-specific tooling. This guide covers creating them, saving them permanently, and the Fish-specific features that make them more useful than Bash functions.

Creating a basic function

function greet
    echo "Hello, $argv"
end

Run greet World and it prints Hello, World. The variable $argv contains all arguments passed to the function. You can access individual arguments with $argv[1], $argv[2], etc.

That function only lasts for the current shell session. Close the terminal and it’s gone. I’ll cover how to make functions permanent in a moment.

Functions with named arguments

Fish doesn’t have named parameters the way Python does, but --argument-names gives you something close:

function mkcd --argument-names dir
    mkdir -p $dir
    cd $dir
end

Now mkcd projects/new-app creates the directory and moves into it. The first argument gets assigned to $dir. Extra arguments are still available through $argv.

You can list multiple argument names:

function connect --argument-names host port
    ssh -p $port $host
end

Adding a description

function ll --description "List files in long format"
    ls -la $argv
end

The description shows up when you run functions or type ll. It’s documentation for future-you.

Saving functions permanently

Fish has three ways to keep functions across sessions.

Method 1: funcsave (the Fish way)

Create a function interactively, then save it:

function weather --argument-names city
    curl "wttr.in/$city?format=3"
end

funcsave weather

This saves the function to ~/.config/fish/functions/weather.fish. Fish automatically loads it when you use the command in any future session — not at startup, but on demand. This lazy loading is why Fish stays fast regardless of how many saved functions you have.

Method 2: Create the file directly

Write the function file yourself:

# ~/.config/fish/functions/weather.fish
function weather --argument-names city
    curl "wttr.in/$city?format=3"
end

Same result as funcsave. I prefer this method for functions I want to version-control with my dotfiles.

Method 3: Put it in config.fish

# ~/.config/fish/config.fish
function weather --argument-names city
    curl "wttr.in/$city?format=3"
end

This works but has two downsides: the function loads on every shell startup (not lazily), and your config.fish gets longer. Use autoloading files for anything beyond trivial functions.

Autoloading rules

For autoloading to work, the file name must match the function name. A function called weather must be in weather.fish. If the file contains multiple functions, only the one matching the filename will autoload.

Editing functions

Fish has a built-in function editor:

funced weather

This opens the function in $EDITOR (or $VISUAL). When you save and close the editor, Fish loads the updated function into your current session. You can then persist it with funcsave weather.

To see a function’s current definition without editing:

functions weather
# or
type weather

Practical function examples

Git shortcut with defaults

function gc --description "Git commit with message"
    if test (count $argv) -eq 0
        echo "Usage: gc <message>"
        return 1
    end
    git add --all
    git commit -m "$argv"
end

Usage: gc "fix login redirect" stages everything and commits with that message.

Quick project switcher

function proj --argument-names name
    set -l base ~/projects
    if test -z "$name"
        ls $base
        return
    end
    if test -d $base/$name
        cd $base/$name
    else
        echo "Project '$name' not found in $base"
        return 1
    end
end

Run proj to list projects, or proj myapp to jump to ~/projects/myapp. Add tab completions for it too:

# ~/.config/fish/completions/proj.fish
complete -c proj -f -a "(ls ~/projects)"

Now proj followed by Tab lists your project directories. See my autocomplete guide for more on writing completions.

Backup with timestamp

function bak --argument-names file
    if test -z "$file"
        echo "Usage: bak <file>"
        return 1
    end
    cp $file $file.bak.(date +%Y%m%d-%H%M%S)
end

bak config.yaml creates config.yaml.bak.20260224-141500.

Docker cleanup

function docker-clean --description "Remove stopped containers, dangling images, unused volumes"
    echo "Removing stopped containers..."
    docker container prune -f
    echo "Removing dangling images..."
    docker image prune -f
    echo "Removing unused volumes..."
    docker volume prune -f
end

Event handlers

Functions can respond to events. This is useful for running code when variables change, when a command finishes, or when Fish exits.

Run code when a variable changes

function __on_pwd_change --on-variable PWD
    if test -f .node-version
        echo "Node version: "(cat .node-version)
    end
end

Every time you change directories, this checks for a .node-version file. The --on-variable PWD flag triggers the function whenever $PWD changes.

Run code on Fish exit

function __on_exit --on-event fish_exit
    echo "Goodbye!"
end

Run code after a command finishes

function __notify_long_command --on-event fish_postexec
    if test $CMD_DURATION -gt 10000
        echo "Command took "(math $CMD_DURATION / 1000)" seconds"
    end
end

This prints a notice when any command takes more than 10 seconds. $CMD_DURATION is a special Fish variable that holds the last command’s execution time in milliseconds.

Event handler naming

Event handler functions should start with double underscores or a unique prefix to avoid name collisions. Also, event handlers in autoloaded files won’t trigger until the function has been loaded once. For handlers that need to work from the start, put them in config.fish or conf.d/.

Scope and variable visibility

Functions have their own local scope by default. Variables set with set -l inside a function aren’t visible outside it:

function test_scope
    set -l secret "hidden"
    echo $secret
end
test_scope  # prints "hidden"
echo $secret  # prints nothing

Use set -g for global variables (visible everywhere in the session) or set -U for universal variables (persist across all Fish sessions):

set -g session_var "I last until you close this terminal"
set -U persistent_var "I survive restarts"

Functions vs abbreviations vs aliases

Fish has three ways to create shortcuts. Here’s when to use each:

FunctionsAbbreviationsAliases
Best forComplex logic, multi-line commandsSimple command shortcutsSimple command wrapping
ExpansionNo expansion, runs as-isExpands on command line before runningWraps as a function internally
History showsFunction nameExpanded commandAlias name
ArgumentsFull $argv handlingLimited (position/regex-based)Pass-through $argv

I use abbreviations for simple shortcuts (gsgit status), and functions for anything that needs logic. I covered abbreviations vs aliases in detail in Fish Shell abbreviations vs aliases.

Managing functions

functions                    # list all defined functions
functions -n                 # list function names only
functions weather            # show a function's definition
functions -e weather         # erase a function
funcsave weather             # save to autoload file
funced weather               # edit in $EDITOR

To delete a saved function permanently:

functions -e weather
funcsave weather

The second command writes the “erased” state, removing the file from ~/.config/fish/functions/.