← Back to DevBytes

Fish Scripting: Aliases and Functions Complete Guide

Understanding Aliases and Functions in Fish

Fish shell takes a fundamentally different approach to command customization compared to Bash and Zsh. Where other shells blur the line between aliases and functions, Fish maintains a clear separation while offering a uniquely streamlined workflow for creating both. This guide covers everything from basic alias syntax to advanced function autoloading patterns.

What Are Fish Aliases?

In Fish, an alias is a simple shorthand—a text substitution that replaces one command string with another. Unlike Bash where "aliases" often secretly behave like functions, Fish aliases are pure syntactic sugar. When you define an alias, Fish literally replaces the alias name with its definition before executing the command.

The syntax is straightforward:

alias NAME "command string"

Here's a basic example:

alias ll "ls -lh"
alias gco "git checkout"
alias dc "docker compose"

When you type ll, Fish substitutes it with ls -lh and then executes that. This substitution happens at the parsing level, so any arguments you pass are naturally appended:

# If you have: alias gco "git checkout"
# Then typing:
gco feature-branch
# Actually executes:
git checkout feature-branch

Why Fish Aliases Matter

The clean separation between aliases and functions in Fish solves several problems that plague other shells:

Viewing and Managing Aliases

Fish provides several commands to inspect your current aliases:

# List all defined aliases
alias

# Check a specific alias
alias ll

# Remove an alias
alias -e ll

Aliases are stored in a special variable and can be manipulated programmatically:

# See all aliases as key-value pairs
functions --all | grep "alias"

# Aliases are stored in the $fish_aliases variable
echo $fish_aliases

Persisting Aliases Across Sessions

Simply defining an alias in an interactive session makes it temporary. To make aliases permanent, add them to your Fish configuration file:

# Add to ~/.config/fish/config.fish
alias ll "ls -lh"
alias gco "git checkout"
alias gst "git status"
alias dc "docker compose"
alias please "sudo"

A powerful pattern is to source aliases from a separate file to keep your config organized:

# In ~/.config/fish/config.fish
if [ -f ~/.config/fish/aliases.fish ]
    source ~/.config/fish/aliases.fish
end

# Then in ~/.config/fish/aliases.fish
alias ll "ls -lh"
alias gco "git checkout"
alias gst "git status"
alias dc "docker compose"
alias please "sudo"
alias yolo "git push --force-with-lease"

The Transition: When to Use Functions Instead

The moment your alias needs logic—loops, conditionals, string manipulation, or complex argument processing—you should switch to a function. Fish makes this transition seamless. Consider this Bash-style alias that should actually be a function:

# Bad alias attempt (this won't work well in Fish)
# alias gpr "git pull --rebase && git push"

# Better as a function
function gpr
    git pull --rebase origin (git branch --show-current)
    and git push origin (git branch --show-current)
end

Fish Functions: The Real Power

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

What Are Fish Functions?

Functions are the backbone of Fish scripting. They are named blocks of code that accept arguments, execute commands, and return exit statuses. Unlike aliases, functions can contain arbitrary logic, loops, conditionals, and variable manipulations. Fish treats functions as first-class citizens—they live in autoloadable files and integrate deeply with the shell's ecosystem.

Creating Functions Interactively

Fish provides the delightful funced command for interactive function creation. This opens your configured editor so you can write the function body immediately:

# Create a new function interactively
funced my_function

# The editor opens with a template:
# function my_function
#     
# end

After you save and close the editor, test your function. Once satisfied, save it permanently:

# Save the function to disk for autoloading
funcsave my_function

This writes the function to ~/.config/fish/functions/my_function.fish, making it automatically available in all future sessions without cluttering your config.fish file.

Manual Function Definition

You can also define functions directly in config.fish or in script files:

function mkcd
    mkdir -p $argv[1]
    and cd $argv[1]
end

Call it naturally:

mkcd my-new-project
# Creates the directory and navigates into it

Function Arguments: $argv

Fish passes arguments to functions through the $argv list variable. This is the single most important concept in Fish function scripting:

function greet
    echo "Hello, $argv[1]!"
end

greet World
# Output: Hello, World!

You can access multiple arguments by index:

function three_args
    echo "First: $argv[1]"
    echo "Second: $argv[2]"
    echo "Third: $argv[3]"
end

To get the total number of arguments, use count:

function check_args
    set arg_count (count $argv)
    echo "You passed $arg_count arguments"
end

Loop over all arguments easily:

function process_all
    for arg in $argv
        echo "Processing: $arg"
    end
end

Argument Handling Patterns

Functions often need to distinguish between flags, options, and positional arguments. Fish offers elegant patterns for this:

function extract
    # Check for help flag
    if set -l index (contains -i -- --help $argv)
        echo "Usage: extract "
        return 0
    end
    
    # Assume first non-flag argument is the file
    set -l file $argv[1]
    
    if test -z "$file"
        echo "Error: No file specified"
        return 1
    end
    
    # Determine extension and extract accordingly
    switch $file
        case "*.tar.gz" "*.tgz"
            tar -xzf $file
        case "*.tar.bz2" "*.tbz2"
            tar -xjf $file
        case "*.tar"
            tar -xf $file
        case "*.zip"
            unzip $file
        case "*.7z"
            7z x $file
        case "*"
            echo "Unknown archive format: $file"
            return 1
    end
end

Return Values and Exit Statuses

Fish functions return an exit status, not arbitrary values. Use return with an integer (0 for success, non-zero for failure):

function is_root
    if test (id -u) -eq 0
        return 0
    else
        return 1
    end
end

# Usage with conditionals
if is_root
    echo "Running as root"
else
    echo "Running as regular user"
end

To return string data, use command substitution (capture stdout):

function get_branch_name
    git rev-parse --abbrev-ref HEAD 2>/dev/null
end

# Capture the output
set branch (get_branch_name)
echo "Current branch: $branch"

Local Variables and Scoping

Fish functions create a local scope automatically. Variables defined with set -l inside a function don't leak out:

function demo_scope
    set -l secret "Only inside this function"
    echo $secret
end

demo_scope
# Output: Only inside this function

echo $secret
# Output: (nothing - variable doesn't exist outside)

Use set -g sparingly when you genuinely need to modify global state:

function set_project_env
    set -g PROJECT_ROOT (pwd)
    set -g PROJECT_NAME (basename $PROJECT_ROOT)
end

The Autoloading Magic

This is where Fish truly shines. When you call a function that isn't defined in the current session, Fish automatically searches ~/.config/fish/functions/ and loads the corresponding .fish file on demand. This means:

The directory structure looks like this:

~/.config/fish/
├── config.fish          # Main configuration (sourced at startup)
├── functions/
│   ├── mkcd.fish        # Autoloaded: function mkcd
│   ├── extract.fish     # Autoloaded: function extract
│   ├── gpr.fish         # Autoloaded: function gpr
│   └── docker_clean.fish # Autoloaded: function docker_clean
└── completions/
    └── ...              # Custom completions

Each function file should contain exactly the function definition (and any helper functions). The naming convention is strict: the file must be named exactly after the function:

# File: ~/.config/fish/functions/mkcd.fish
function mkcd
    mkdir -p $argv[1]
    and cd $argv[1]
end

You can also create subdirectories for organization, though Fish doesn't recursively search them by default:

# For subdirectory autoloading, add to config.fish:
set fish_function_path $fish_function_path ~/.config/fish/functions/utils
set fish_function_path $fish_function_path ~/.config/fish/functions/git

Advanced Function Techniques

Event-Driven Functions

Fish supports special function names that hook into shell events. These are incredibly powerful for customizing your environment:

# Run whenever the prompt is about to be displayed
function fish_prompt
    set -l last_status $status
    set -l prompt_symbol "❯"
    
    # Color the prompt symbol red if last command failed
    if test $last_status -ne 0
        set_color red
    else
        set_color green
    end
    
    echo -n (prompt_pwd) " $prompt_symbol "
    set_color normal
end

# Run when changing directories
function fish_cursor --on-event fish_postexec
    # Reset cursor after command execution
    echo -n ""
end

# Run when a new Fish session starts
function fish_greeting
    echo "Welcome back! Today is "(date "+%A, %B %d")
    echo "Your current branch:" (git branch --show-current 2>/dev/null)
end

Key event functions include:

Functions That Modify Shell State

Some functions need to modify the shell's environment (change directories, set environment variables in the parent shell). In Bash this requires source or eval tricks. Fish handles this elegantly with the --on-shell flag:

function up
    set -l count 1
    if test -n "$argv[1]"
        set count $argv[1]
    end
    
    set -l path (string repeat -n $count "../")
    cd $path
end

Since cd inside a function affects the calling shell by default in Fish (unlike Bash where functions run in a subshell for pipelines), this just works.

Using Functions with Pipes and Command Substitution

Fish functions integrate naturally with pipes. The exit status propagates correctly:

function filter_logs
    grep "ERROR" $argv[1] | awk '{print $2, $NF}' | sort -u
end

# Use in a pipeline chain
filter_logs app.log | tee errors.txt | wc -l

Command substitution captures function output:

function get_docker_containers
    docker ps --format "{{.Names}}" 2>/dev/null
end

set containers (get_docker_containers)
for container in $containers
    echo "Found container: $container"
end

Recursive Functions

Fish fully supports recursion, useful for traversing trees or processing nested data:

function tree
    set -l indent ""
    if test -n "$argv[2]"
        set indent $argv[2]
    end
    
    set -l path $argv[1]
    echo "$indent📁 "(basename $path)
    
    for item in (ls -1 $path)
        set -l fullpath "$path/$item"
        if test -d "$fullpath"
            tree "$fullpath" "$indent  "
        else
            echo "$indent  📄 $item"
        end
    end
end

Error Handling in Functions

Robust functions handle failures gracefully. Fish provides several mechanisms:

function safe_delete
    # Validate input
    if test -z "$argv[1]"
        echo "Error: No file specified" >&2
        return 1
    end
    
    if not test -e "$argv[1]"
        echo "Error: File does not exist: $argv[1]" >&2
        return 1
    end
    
    # Confirm destructive action
    echo "Delete $argv[1]? (y/N)"
    read -l confirmation
    
    if test "$confirmation" != "y"
        echo "Aborted."
        return 0
    end
    
    # Perform the operation with error checking
    if rm -rf "$argv[1]"
        echo "Successfully deleted: $argv[1]"
        return 0
    else
        echo "Error: Failed to delete $argv[1]" >&2
        return 1
    end
end

The $status variable holds the exit status of the last command, and you can use and/or chains for concise error handling:

function deploy
    echo "Building..."
    make build
    and echo "Build succeeded, testing..."
    and make test
    and echo "Tests passed, deploying..."
    and make deploy
    or echo "Deploy pipeline failed at some stage" >&2
end

Best Practices

1. Keep Aliases Simple

If your alias requires more than one line or involves any logic, make it a function. A good rule of thumb: aliases are for abbreviations, functions are for behavior.

# Good alias: simple abbreviation
alias gp "git push"

# Good function: involves logic
function gpr
    set -l branch (git branch --show-current)
    git pull --rebase origin $branch
    and git push origin $branch
end

2. Use funcsave Religiously

After interactive development with funced, always run funcsave. It writes your function to the autoloading directory with proper permissions and syntax. Avoid manually copying function definitions into config.fish—it slows startup and creates maintenance burden.

3. Follow Naming Conventions

Use lowercase for your functions (Fish's built-in functions are uppercase, so this avoids conflicts). Use descriptive names with underscores or hyphens:

# Clear, descriptive names
function docker-clean
function git-sync
function project-init
function db-backup

4. Document Your Functions

Add comments at the top of function files explaining usage. Fish even supports a special comment format that integrates with functions --details:

# File: ~/.config/fish/functions/docker-clean.fish
#
# NAME: docker-clean
# DESCRIPTION: Remove all stopped containers and dangling images
# USAGE: docker-clean [--force]
#
function docker-clean
    set -l force_flag ""
    if contains -- "--force" $argv
        set force_flag "-f"
    end
    
    docker container prune $force_flag
    and docker image prune $force_flag
end

5. Test Functions Interactively

Fish's REPL-like development cycle is a gift. Use it:

# 1. Draft the function
funced my_function

# 2. Test it immediately
my_function test_arg

# 3. Edit if needed
funced my_function

# 4. Save when satisfied
funcsave my_function

6. Leverage Fish's Built-in String Tools

Fish has exceptional built-in string manipulation. Use string commands instead of external tools when possible:

function slugify
    echo $argv[1] | string lower | string replace -a -r "[^a-z0-9]" "-" | string replace -a -r "-+" "-" | string trim -c "-"
end

7. Handle Missing Dependencies

Check for required commands at the start of functions that depend on external tools:

function optimize_images
    if not command -sq convert
        echo "Error: ImageMagick is required but not installed" >&2
        return 1
    end
    
    for img in $argv
        convert "$img" -resize 1200x1200\> -quality 85 "optimized_$img"
    end
end

8. Use the $status Variable

Always check $status after commands that might fail. Fish's and/or chains are idiomatic and readable:

function build_and_run
    cmake -B build -S .
    and cmake --build build
    and ./build/bin/my_app $argv
    or echo "Build or run failed with status $status" >&2
end

Common Patterns and Real-World Examples

Project Bootstrap Function

function scaffold-python-project
    set -l project_name $argv[1]
    
    if test -z "$project_name"
        echo "Usage: scaffold-python-project " >&2
        return 1
    end
    
    if test -d "$project_name"
        echo "Error: Directory '$project_name' already exists" >&2
        return 1
    end
    
    mkdir -p "$project_name/$project_name"
    mkdir -p "$project_name/tests"
    
    # Create pyproject.toml
    echo "[project]
name = '$project_name'
version = '0.1.0'
description = ''
readme = 'README.md'
requires-python = '>=3.11'
" > "$project_name/pyproject.toml"
    
    # Create initial module
    echo "__version__ = '0.1.0'
" > "$project_name/$project_name/__init__.py"
    
    # Create test file
    echo "def test_version():
    from $project_name import __version__
    assert __version__ == '0.1.0'
" > "$project_name/tests/test_version.py"
    
    # Initialize git
    cd "$project_name"
    git init
    echo "# $project_name" > README.md
    echo "*.pyc
__pycache__/
.pytest_cache/
dist/
" > .gitignore
    
    echo "✅ Project '$project_name' scaffolded successfully"
    echo "📁 Location: "(pwd)
end

Git Workflow Automation

function git-sync-fork
    # Sync a forked repository with upstream
    set -l upstream_branch "main"
    if test -n "$argv[1]"
        set upstream_branch $argv[1]
    end
    
    if not git remote | grep -q "^upstream\$"
        echo "Error: No 'upstream' remote configured" >&2
        echo "Add one with: git remote add upstream " >&2
        return 1
    end
    
    echo "Fetching upstream..."
    git fetch upstream
    
    echo "Checking out $upstream_branch..."
    git checkout $upstream_branch
    
    echo "Merging upstream/$upstream_branch..."
    git merge upstream/$upstream_branch
    
    echo "Pushing to origin..."
    git push origin $upstream_branch
    
    echo "✅ Fork synced successfully"
end

Smart Directory Navigation

function jump
    # Jump to a project directory using fuzzy finding
    set -l projects_dir ~/projects
    
    if not test -d "$projects_dir"
        echo "Error: Projects directory not found: $projects_dir" >&2
        return 1
    end
    
    if test -z "$argv[1]"
        echo "Usage: jump " >&2
        return 1
    end
    
    # Find matching directories
    set -l matches (find "$projects_dir" -maxdepth 3 -type d -iname "*$argv[1]*" 2>/dev/null)
    
    if test -z "$matches"
        echo "No directories matching '$argv[1]'" >&2
        return 1
    end
    
    if test (count $matches) -eq 1
        cd "$matches[1]"
        echo "Jumped to: $matches[1]"
    else
        echo "Multiple matches found:"
        set -l i 1
        for match in $matches
            echo "[$i] $match"
            set i (math $i + 1)
        end
        
        echo -n "Select number (or q to quit): "
        read -l choice
        
        if test "$choice" = "q"
            return 0
        end
        
        if test "$choice" -gt 0 -a "$choice" -le (count $matches) 2>/dev/null
            cd "$matches[$choice]"
            echo "Jumped to: $matches[$choice]"
        else
            echo "Invalid selection" >&2
            return 1
        end
    end
end

Docker Cleanup Function

function docker-clean
    set -l dry_run 0
    
    if contains -- "--dry-run" $argv
        set dry_run 1
    end
    
    echo "=== Docker Cleanup ==="
    
    # Show what would be cleaned
    echo ""
    echo "Stopped containers:"
    docker container ls -f "status=exited" --format "{{.ID}} {{.Names}}"
    
    echo ""
    echo "Dangling images:"
    docker images -f "dangling=true" --format "{{.ID}} {{.Repository}}"
    
    echo ""
    echo "Unused volumes:"
    docker volume ls -f "dangling=true" --format "{{.Name}}"
    
    if test $dry_run -eq 1
        echo ""
        echo "🔍 Dry run complete. Use without --dry-run to actually clean."
        return 0
    end
    
    echo ""
    echo -n "Proceed with cleanup? (y/N) "
    read -l confirm
    
    if test "$confirm" != "y"
        echo "Aborted."
        return 0
    end
    
    echo "Cleaning up..."
    docker container prune -f
    and docker image prune -f
    and docker volume prune -f
    
    echo "✅ Docker cleanup complete"
end

Troubleshooting and Debugging

Inspecting Function Definitions

Fish provides introspection tools to examine functions:

# See function source code
functions my_function

# Show where a function is defined
functions --details my_function

# List all functions (including Fish's built-in ones)
functions --all

# List only user-defined functions
functions --handlers

Debugging with fish --debug

Enable debug output to trace function execution:

# Start fish with debug output
fish --debug-level=3

# Or set it within a session
set -g fish_trace 1

# Run your function and observe the trace
my_function arg1 arg2

# Turn off tracing
set -g fish_trace 0

Common Pitfalls

Migrating from Bash

If you're coming from Bash, here's a quick translation guide for common patterns:

# Bash alias → Fish alias (nearly identical)
# Bash: alias ll='ls -lh'
# Fish: alias ll "ls -lh"

# Bash function → Fish function
# Bash:
# my_func() { echo "Hello $1"; }
#
# Fish:
# function my_func
#     echo "Hello $argv[1]"
# end

# Bash: if [ $? -eq 0 ]; then
# Fish: if test $status -eq 0

# Bash: $@ or $*
# Fish: $argv

# Bash: for i in "$@"; do
# Fish: for i in $argv

# Bash: local var="value"
# Fish: set -l var "value"

# Bash: export VAR="value"
# Fish: set -gx VAR "value"

# Bash: source ~/.bashrc
# Fish: source ~/.config/fish/config.fish

# Bash: alias with arguments (hacky)
# alias gc='git commit -m "$1"' # doesn't work
# Fish solution: just make a function
# function gc
#     git commit -m "$argv[1]"
# end

Conclusion

Fish's alias and function system represents a thoughtful reimagining of shell customization. By keeping aliases as simple text substitutions and elevating functions to first-class, autoloadable citizens, Fish eliminates the confusion and fragility that plague other shells. The funced/funcsave workflow turns function development into a fluid, iterative process rather than a tedious edit-source-reload cycle. Whether you're writing a quick abbreviation for a lengthy command or building a sophisticated project automation script, Fish provides the right level of abstraction. The key to mastering Fish scripting lies in recognizing when an alias suffices and when a function is called for—and in embracing the autoloading directory as your primary code organization mechanism. With these tools and patterns, your shell becomes not just an interface but a programmable environment tailored precisely to how you work.

🚀 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