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:
- No quoting nightmares: Bash users frequently struggle with nested quotes in alias definitions. Fish eliminates this by encouraging functions for anything complex.
- Predictable argument handling: Arguments are always appended naturally—no need for
!notation or placeholder variables in aliases. - Performance: Fish aliases are resolved at parse time, not runtime, making them extremely fast.
- Debugging clarity: Since aliases are simple substitutions, there's no mysterious behavior to trace when something goes wrong.
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:
- Fast startup: You don't load hundreds of functions at shell startup—only when needed.
- Organized code: Each function lives in its own file, making version control and sharing easy.
- No manual sourcing: Just drop a
.fishfile in the functions directory and it's instantly available.
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:
fish_prompt— Customize your promptfish_right_prompt— Right-side promptfish_greeting— Welcome message on new sessionsfish_command_not_found— Handle unknown commandsfish_title— Terminal window title
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
- Forgetting $argv indexing starts at 1: Fish arrays are 1-indexed, not 0-indexed like most languages.
$argv[1]is the first argument. - Using Bash-style
$1,$2: These don't exist in Fish. Always use$argv[1],$argv[2]. - Not quoting variables in test conditions: Use
test -n "$var"nottest -n $varto avoid errors with empty variables. - Assuming functions run in a subshell: Unlike Bash pipeline components, Fish functions run in the current shell context by default.
- Confusing alias and function syntax: Aliases use quotes around the entire replacement string; functions use the
function...endblock syntax.
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.