← Back to DevBytes

Fish Scripting: Functions Complete Guide

What Are Fish Functions?

In Fish shell, functions are the fundamental building blocks for organizing and reusing commands. Unlike traditional shells like Bash that blur the line between functions and scripts, Fish treats functions as first-class citizens. A Fish function is a named block of commands that can accept arguments, define local variables, and return exit statuses — just like a built-in command.

Fish functions can live in several places: defined interactively in your session, stored in configuration files like ~/.config/fish/config.fish, or placed in dedicated autoload directories. This flexibility allows you to build everything from simple aliases to complex, modular command suites.

Key Characteristics

Why Fish Functions Matter

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Functions transform Fish from a simple interactive shell into a powerful scripting environment. They let you encapsulate logic, reduce repetition, and create domain-specific languages tailored to your workflow. For developers, this means cleaner shell scripts, easier debugging, and a more maintainable codebase over time.

Because Fish functions support autoloading, you can build entire toolkits — think Git helpers, Docker shortcuts, project scaffolding commands — that load lazily only when invoked. This keeps your shell startup snappy even with hundreds of custom commands defined.

Defining Your First Function

The simplest way to define a function interactively is using the function built-in, followed by the body and terminated with end. Here is a basic greeting function:

function greet
    echo "Hello, $argv! Welcome to Fish."
end

Call it like any other command:

greet World
# Output: Hello, World! Welcome to Fish.

Notice that $argv is a special list variable containing all arguments passed to the function. You can access individual arguments with index notation like $argv[1] or iterate over all of them with a for loop.

Functions with Descriptions

Add a description using the --description flag so your function appears with context in tab completions and listings:

function greet --description "Print a friendly greeting to the given name"
    echo "Hello, $argv! Welcome to Fish."
end

Now running functions --details greet will display your description, and users tab-completing will see helpful information.

Function Arguments and $argv

Every function receives its arguments in the $argv list. This is one of Fish's greatest usability improvements over legacy shells: no confusing $1, $2, $@, or quoting nightmares. $argv is always a list, and list operations work intuitively.

function show-args
    echo "You passed" (count $argv) "arguments:"
    for arg in $argv
        echo "  - $arg"
    end
end

show-args foo bar baz
# Output:
# You passed 3 arguments:
#   - foo
#   - bar
#   - baz

Handling Optional Arguments with Defaults

Use Fish's conditional expansion to provide default values when arguments are missing:

function greet --description "Greet someone, defaulting to World"
    set name $argv[1]
    if test -z "$name"
        set name "World"
    end
    echo "Hello, $name!"
end

greet
# Output: Hello, World!

greet Alice
# Output: Hello, Alice!

Or more concisely using Fish's default value syntax:

function greet
    set name (echo $argv[1] | or echo "World")
    echo "Hello, $name!"
end

Named Arguments with argparse

For complex functions with flags and options, Fish provides argparse — a built-in argument parser that rivals dedicated libraries in other languages:

function deploy --description "Deploy application to environment"
    argparse -n deploy 'h/help' 'e/env=' 't/timeout=' -- $argv
    or return

    if set -q _flag_help
        echo "Usage: deploy [-h] [-e ENV] [-t TIMEOUT]"
        return 0
    end

    set -q _flag_env; or set _flag_env "production"
    set -q _flag_timeout; or set _flag_timeout "300"

    echo "Deploying to $_flag_env with timeout $_flag_timeout seconds..."
end

deploy -e staging -t 120
# Output: Deploying to staging with timeout 120 seconds...

Key argparse patterns:

Variable Scoping in Functions

Fish uses block-scoped variables. Inside a function, variables are local by default when declared with set. This prevents the common shell scripting pitfall of accidentally overwriting global variables.

set -g GLOBAL_COUNT 0

function increment-global
    set -g GLOBAL_COUNT (math $GLOBAL_COUNT + 1)
    echo "Global count is now $GLOBAL_COUNT"
end

function local-example
    set LOCAL_VAR "I am local"
    echo $LOCAL_VAR
end

increment-global
increment-global
local-example
echo $LOCAL_VAR  # Nothing! LOCAL_VAR does not exist here.

Explicit scope flags give you precise control:

Return Values and Exit Statuses

Fish functions communicate results through two mechanisms: exit statuses (success/failure) and explicit output to stdout. The exit status of the last command run becomes the function's exit status. Use return with an integer to explicitly set it.

function is-even --description "Return success if number is even"
    set num (math "$argv[1] % 2")
    if test $num -eq 0
        return 0
    else
        return 1
    end
end

is-even 4; and echo "4 is even"
# Output: 4 is even

is-even 7; or echo "7 is odd"
# Output: 7 is odd

To return data, echo it to stdout and capture it with command substitution:

function multiply
    math "$argv[1] * $argv[2]"
end

set result (multiply 6 7)
echo "6 × 7 = $result"
# Output: 6 × 7 = 42

The Autoloading System

Fish's autoloading mechanism is one of its most powerful features. You don't need to manually source function files — Fish discovers them on demand by searching specific directories. This works as follows:

  1. When you call an unknown command, Fish checks if a function file exists with that name
  2. It searches $fish_function_path directories in order
  3. If found, it loads the function and executes it — all transparently

Create a function file by placing a .fish file in the right directory. The file must contain the function definition without the function/end wrapper — just the body content. Fish wraps it automatically on load.

Setting Up Autoload Directories

# Create a custom autoload directory
mkdir -p ~/.config/fish/functions/custom

# Add it to your function path (in config.fish)
set -p fish_function_path ~/.config/fish/functions/custom

# Create an autoloadable function file
echo 'echo "Building project $argv in (pwd)..."
# Build commands here' > ~/.config/fish/functions/custom/build.fish

# Now it works immediately in new sessions
build my-app
# Output: Building project my-app in /home/user/projects...

The standard Fish function directories include:

Lazy Loading Pattern

Autoloading enables a powerful pattern: create small wrapper functions that load heavy dependencies only when needed. The actual function body file is loaded on first invocation.

# In ~/.config/fish/functions/heavy-tool.fish
# This file is autoloaded only when 'heavy-tool' is first called
if not set -q _heavy_tool_initialized
    pip install --quiet some-heavy-package
    set -g _heavy_tool_initialized true
end
heavy-tool-command $argv

Event-Driven Functions

Fish supports special function names that automatically trigger on shell events. These are powerful hooks for customizing your environment without polluting your startup sequence.

# Runs whenever the current working directory changes
function fish_prompt --description "Custom prompt with git info"
    set -l git_branch (git branch --show-current 2>/dev/null)
    set -l prompt "["(set_color blue)"$PWD"(set_color normal)"]"
    if test -n "$git_branch"
        set prompt "$prompt ("(set_color yellow)"$git_branch"(set_color normal)")"
    end
    echo "$prompt \$ "
end

# Runs when Fish exits
function fish_remove_universal --on-event fish_exit
    echo "Cleaning up temporary universal variables..."
    set -e MY_TEMP_VAR
end

Common event function names:

Advanced Patterns

Functions That Accept Pipelines

Functions can consume stdin, making them perfect pipeline citizens. Use read to process incoming data:

function summarize --description "Count lines, words, and characters from stdin"
    set -l lines 0
    set -l words 0
    set -l chars 0
    while read -l line
        set lines (math $lines + 1)
        set words (math $words + (count (string split " " -- $line)))
        set chars (math $chars + (string length "$line"))
    end
    echo "Lines: $lines, Words: $words, Characters: $chars"
end

ls -la | summarize
# Output: Lines: 15, Words: 45, Characters: 520

Recursive Functions

Fish functions can call themselves. Here is a classic factorial example demonstrating recursion:

function factorial --description "Compute factorial recursively"
    if test $argv[1] -le 1
        echo 1
        return
    end
    set -l prev (factorial (math "$argv[1] - 1"))
    math "$argv[1] * $prev"
end

factorial 5
# Output: 120

Functions That Modify Global State Safely

When a function needs to persist state across invocations, use universal variables with clear naming conventions to avoid collisions:

function cache-set --description "Store a value in a named cache"
    set -U cache_$argv[1] $argv[2]
end

function cache-get --description "Retrieve a value from cache"
    set -q cache_$argv[1]; and echo $cache_$argv[1]
end

cache-set user_name "Alice"
cache-get user_name
# Output: Alice

Function Decorators / Wrappers

You can create functions that wrap existing commands to add logging, timing, or pre/post hooks:

function timed --description "Run a command and report elapsed time"
    set -l start (date +%s.%N)
    $argv
    set -l exit_code $status
    set -l end (date +%s.%N)
    set -l elapsed (math "$end - $start")
    printf "Completed in %.3f seconds (exit: %d)\n" $elapsed $exit_code
    return $exit_code
end

timed sleep 2
# Output: Completed in 2.001 seconds (exit: 0)

Debugging Fish Functions

Fish provides built-in tools for inspecting and debugging functions without external dependencies:

# List all functions with their descriptions
functions --all --details

# Show the source code of a specific function
functions --details greet
functions --body greet

# Trace function execution with fish_trace
set -g fish_trace 1
greet World
# Shows every command executed within the function

# Turn off tracing
set -g fish_trace 0

For more targeted debugging, use breakpoint inside a function to drop into an interactive prompt:

function complex-operation
    set -l step1 (fetch-data)
    breakpoint  # Inspect variables, step through manually
    set -l step2 (process-data $step1)
    echo $step2
end

At the breakpoint, you can inspect all local variables, run arbitrary commands, and type exit to resume execution.

Best Practices for Fish Functions

1. Always Add Descriptions

Every function you intend to keep should have a --description. It takes seconds to add and pays dividends when you revisit code months later or share functions with a team.

function deploy --description "Deploy application to specified environment"
    # ...
end

2. Use argparse for Complex Parameter Handling

Resist the temptation to manually parse $argv with string matching. argparse is built-in, fast, and produces consistent error messages that users expect.

3. Keep Functions Focused

A function should do one thing well. If your function exceeds 50 lines, consider splitting it into helper functions. Autoloading makes this cheap — each helper lives in its own file.

4. Prefer Local Variables

Default to local scope. Only use -g or -U when you explicitly need persistence or cross-function communication. This prevents hard-to-debug state leaks.

5. Handle Errors Explicitly

Use or chains to handle failures gracefully rather than ignoring exit statuses:

function safe-delete
    rm -rf "$argv[1]"; or begin
        echo "Failed to delete $argv[1]" >&2
        return 1
    end
end

6. Use the Autoload System Properly

Place standalone function files in ~/.config/fish/functions/ with the bare body (no function/end wrapper). Fish handles the wrapping. This keeps your config.fish lean and startup fast.

7. Test Functions Interactively

Define functions in-session first, test them thoroughly, then persist them to disk only when they're stable. Use funcsave to save a function defined interactively:

funcsave greet
# Saves the 'greet' function to ~/.config/fish/functions/greet.fish

8. Document with Comments Inside the Function Body

Since autoload files don't contain the function/end wrapper, comments at the top of the file serve as excellent documentation visible in functions --details output:

# deploy.fish — Deploy application to environments
# Usage: deploy [-e ENV] [-t TIMEOUT]
argparse 'e/env=' 't/timeout=' -- $argv
# ... rest of function body

Common Pitfalls and How to Avoid Them

Conclusion

Fish functions elevate shell scripting from a stringly-typed guessing game into a structured, discoverable, and genuinely pleasant programming experience. By embracing $argv lists, argparse for argument handling, local scoping, and the autoloading system, you can build a personal toolkit of commands that feels native to the shell while remaining maintainable over years of use. Start with small, focused functions, add descriptions early, and let the autoloading system handle discovery — your future self will thank you every time you type a command and it just works.

🚀 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