Debugging Fish Scripts: A Complete Guide
Debugging Fish scripts is the process of identifying, isolating, and fixing errors in your shell scripts written for the Fish (Friendly Interactive Shell) environment. Unlike traditional POSIX shells like Bash or Zsh, Fish offers a unique set of debugging features, error messages, and introspection tools that make troubleshooting significantly more approachable — once you know how to leverage them properly.
What Makes Fish Debugging Different
Fish was designed from the ground up with user-friendliness in mind, and this philosophy extends to its scripting error handling. Key differences from other shells include:
- Rich, color-coded error messages that point directly to the problem location
- Automatic suggestion of fixes for common mistakes like unclosed quotes or misspelled commands
- Built-in syntax checking that catches errors before execution, not mid-run
- A dedicated breakpoint and trace system via special flags and variables
- No confusing subshell pitfalls — Fish uses a cleaner variable scoping model
Why Debugging Fish Scripts Matters
Even the most carefully written Fish scripts can fail. Understanding debugging techniques matters because:
- Fish's syntax differs significantly from Bash — copy-pasting Bash snippets into Fish scripts is a common source of bugs
- Variable scoping rules (universal, global, local) can produce unexpected behavior if misunderstood
- Command substitutions and quoting follow different rules that trip up developers coming from POSIX shells
- Functions and autoloading introduce subtle timing and path-related issues
- Interactive mode vs. script mode — some features behave differently between the two
Built-in Debugging Tools in Fish
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The --debug-level Flag
Fish provides a powerful built-in debug infrastructure accessible via the --debug-level option when launching fish, or by setting the $fish_debug_level variable within a running session. This controls the verbosity of internal tracing output.
# Launch fish with debug tracing enabled
fish --debug-level=3
# Or set it inside an existing session
set -g fish_debug_level 2
# Available levels:
# 0 - No debugging (default)
# 1 - Basic error information
# 2 - Warnings and some trace output
# 3 - Full trace output including function calls
# 4 - Extremely verbose (internal details)
The --debug-output Flag
By default, debug output goes to stderr. You can redirect it to a file for later analysis:
# Save debug output to a file
fish --debug-level=3 --debug-output=/tmp/fish_debug.log ./my_script.fish
# The debug file will contain detailed trace information
cat /tmp/fish_debug.log
Using $fish_trace for Execution Tracing
The special variable $fish_trace causes Fish to print every command before it executes, similar to Bash's set -x. This is invaluable for understanding control flow.
#!/usr/bin/env fish
# Enable tracing at the top of your script
set -g fish_trace 1
function calculate_total
set -l subtotal (math "10 * 5")
set -l tax (math "$subtotal * 0.08")
echo "Total: "(math "$subtotal + $tax")
end
calculate_total
# Output will show each line as it executes:
# + set -l subtotal (math "10 * 5")
# + set -l tax (math "$subtotal * 0.08")
# + echo "Total: "(math "$subtotal + $tax")
# Total: 54
Setting Breakpoints with breakpoint
Fish includes a dedicated breakpoint function that pauses script execution and drops you into an interactive debug prompt. This is the most powerful debugging tool available.
#!/usr/bin/env fish
function debug_me
set -l name "World"
set -l counter 42
# Insert a breakpoint — execution pauses here
breakpoint
echo "Hello, $name! Counter is $counter"
end
debug_me
# When the breakpoint triggers, you get an interactive prompt:
# [Paused at line 7 in function 'debug_me']
# You can inspect variables, run commands, or type 'exit' to continue
# fish> echo $name
# World
# fish> echo $counter
# 42
# fish> exit # resumes execution
Inspecting Variables During a Breakpoint
While paused at a breakpoint, you have full access to the current scope. Here's what you can do:
# At the breakpoint prompt (fish>), you can:
# 1. Print all variables in current scope
fish> set --show
# 2. Check a specific variable
fish> echo $counter
# 3. Modify variables to test different scenarios
fish> set counter 99
# 4. List all defined functions
fish> functions --all
# 5. Check the call stack
fish> set --show stack
# 6. Continue execution
fish> exit
# 7. Abort the script entirely
fish> exit 1
Common Error Patterns and How to Diagnose Them
Unclosed Quotes and Strings
Fish immediately flags unclosed quotes with a specific, colorful error message before execution even begins:
# This will fail at parse time, not runtime
echo "Hello, world
# Fish output:
# fish: Unexpected end of string, expected closing quote
# echo "Hello, world
# ^~~~~~~~~~~~^
# The caret points directly to the problem
Command Not Found Errors
Fish goes beyond simply saying "command not found." It provides suggestions and context:
# Misspelled command
git comit -m "Fix bug"
# Fish output:
# fish: Unknown command: git comit
# Did you mean 'git commit'? [Y/n]
# The shell actively helps you fix the issue
Variable Expansion Issues
Fish uses $variable syntax but handles quoting differently than Bash. A common bug involves unquoted variables containing spaces:
#!/usr/bin/env fish
set file_path "/home/user/My Documents/report.txt"
# WRONG — the space in the path causes word splitting
cat $file_path
# CORRECT — always quote variables containing paths or spaces
cat "$file_path"
# Debug with fish_trace to see the difference
set -g fish_trace 1
cat $file_path
# + cat /home/user/My Documents/report.txt
# fish: cat: /home/user/My: No such file or directory
Function Scope Confusion
Variables in Fish have block-local scope by default. This catches many developers off guard:
#!/usr/bin/env fish
function outer
set -l message "I am local to outer"
inner
echo "After inner: $message"
end
function inner
# This creates a NEW local variable, doesn't modify outer's
set -l message "I am local to inner"
echo "Inside inner: $message"
end
outer
# Output:
# Inside inner: I am local to inner
# After inner: I am local to outer
# To modify a variable from an outer scope, use set --global or pass by reference
Math Expression Errors
Fish's math command has strict syntax requirements. Debugging math issues:
# Common math mistakes
math "5 + " # Missing operand — parse error
math "5 / 0" # Division by zero
math "scale=2; 10/3" # Use scale for floating point
# Debug math with verbose output
set -g fish_debug_level 2
math "5 + "
# fish: math: Error: Unexpected end of expression
# The debug output shows the parser state
Advanced Debugging Techniques
Creating Custom Debug Functions
For complex scripts, build your own debug infrastructure:
#!/usr/bin/env fish
# Custom debug function with levels
function debug_msg -d "Print debug messages with severity"
# Usage: debug_msg LEVEL "message"
set -l level $argv[1]
set -l msg $argv[2]
set -l threshold 2
if test $level -le $threshold
set -l prefix (set_color yellow)"[DEBUG $level]"(set_color normal)
echo "$prefix $msg" >&2
end
end
function debug_dump_vars -d "Dump all local variables"
debug_msg 1 "=== Variable Dump at "(status current-function)" ==="
set --show | while read -l line
debug_msg 1 " $line"
end
end
function process_data
set -l input_file $argv[1]
debug_msg 2 "Starting process_data with: $input_file"
if not test -f "$input_file"
debug_msg 1 "ERROR: File not found: $input_file"
return 1
end
set -l line_count (count < "$input_file")
debug_msg 2 "Found $line_count lines"
debug_dump_vars
# Process data...
end
process_data "/tmp/mydata.txt"
Using status for Stack Inspection
The status command reveals the current execution context:
#!/usr/bin/env fish
function level_three
# Get the current stack trace
echo "Current function: "(status current-function)
echo "Current line: "(status current-line-number)
echo "Current file: "(status current-filename)
echo ""
# Print full stack trace
echo "Stack trace:"
status stack-trace
echo ""
# Check if we're in interactive mode
if status is-interactive
echo "Running interactively"
else
echo "Running in script mode"
end
end
function level_two
level_three
end
function level_one
level_two
end
level_one
# Output:
# Current function: level_three
# Current line: 4
# Current file: /path/to/script.fish
#
# Stack trace:
# level_three called at line 20 in file /path/to/script.fish
# level_two called at line 24 in file /path/to/script.fish
# level_one called at line 28 in file /path/to/script.fish
Debugging Signal Handlers and Traps
Fish handles signals differently from POSIX shells. Debugging event handlers requires special attention:
#!/usr/bin/env fish
function on_sigint --on-signal INT
debug_msg 1 "Caught SIGINT in "(status current-function)
# Cleanup logic here
end
function long_running_task
echo "Starting task... PID: "(fish_pid)
set -g fish_debug_level 2
# Simulate long work
for i in (seq 1 10)
echo "Step $i"
sleep 1
end
end
# To debug signal handling, send signals from another terminal:
# kill -INT
# The debug output will show the signal handler activation
Debugging Command Substitutions
Command substitutions in Fish use () rather than backticks or $(). When they fail silently, debugging is crucial:
#!/usr/bin/env fish
# Enable trace to see substitution execution
set -g fish_trace 1
# Problematic substitution — what if the command fails?
set result (grep "pattern" nonexistent_file.txt)
# The substitution runs in a subshell; errors may be hidden
# Fix: capture stderr and check status
set -l output (grep "pattern" nonexistent_file.txt 2>&1)
set -l exit_code $status
if test $exit_code -ne 0
echo "Substitution failed with code $exit_code" >&2
echo "Output: $output" >&2
return 1
end
echo "Result: $result"
Structured Debugging Workflow
Step 1: Reproduce the Bug Consistently
Create a minimal reproduction case. Strip away everything not related to the problem:
# Start with your full script, then progressively reduce it
# until you have the smallest possible failing example
# Original complex script
function full_pipeline
fetch_data | process | validate | output_report
end
# Minimal reproduction — isolate the failing step
function minimal_repro
echo "test data" | process
# Confirm this still fails
end
# Now debug the minimal case
fish --debug-level=3 minimal_repro.fish
Step 2: Enable Trace and Observe
# Add tracing at the top of your script
set -g fish_trace 1
# Or enable it for a specific section
function buggy_function
set -l old_trace $fish_trace
set -g fish_trace 1
# ... suspect code here ...
set -g fish_trace $old_trace
end
Step 3: Insert Strategic Breakpoints
# Place breakpoints at key decision points
function process_item
set -l item $argv[1]
if test -z "$item"
breakpoint # Break if we get empty input
return 1
end
set -l processed (transform "$item")
if test $status -ne 0
breakpoint # Break on transform failure
return 1
end
echo $processed
end
Step 4: Inspect State at Each Breakpoint
# At each breakpoint, systematically check:
# 1. Variables
set --show
# 2. Function arguments
echo $argv
# 3. Exit status of previous command
echo $status
# 4. Current working directory
echo $PWD
# 5. Environment variables
env | grep -E '^(PATH|HOME|USER)'
# 6. File existence and permissions
ls -la "$critical_file"
Debugging Specific Fish Features
Debugging Fish Functions and Autoloading
Fish autoloads functions from specific directories. When a function isn't found, debugging the autoload path is essential:
# Check where Fish looks for functions
echo $fish_function_path
# Typical paths:
# /home/user/.config/fish/functions
# /usr/share/fish/functions
# /etc/fish/functions
# List all autoloaded functions
functions --all --details
# Manually load a function for debugging
source /path/to/function.fish
# Check if a specific function exists
if functions --query my_function
echo "Function exists"
else
echo "Function not found — check fish_function_path"
end
Debugging Fish Configuration Issues
Problems often originate in config files. Fish loads configuration in a specific order:
# Debug config loading sequence
fish --debug-level=3 --init-command="echo 'Config debug'" 2>&1 | grep -A5 "config"
# Common config files loaded in order:
# 1. /usr/share/fish/config.fish (system-wide)
# 2. ~/.config/fish/config.fish (user config)
# 3. ~/.config/fish/fish.conf.d/*.fish (conf.d snippets)
# To isolate a config problem, start fish with no config
fish --no-config
# Then source configs one by one
fish --no-config --init-command="source ~/.config/fish/config.fish"
Debugging Prompt and Interactive Features
Fish prompt functions run frequently and can cause performance issues:
# Debug slow prompts
function fish_prompt
set -g fish_debug_level 1
set -g fish_trace 1
# Your prompt logic...
echo -n (prompt_pwd) '> '
set -g fish_trace 0
set -g fish_debug_level 0
end
# Time your prompt execution
function fish_prompt
set -l start_time (date +%s%N)
# Generate prompt...
set -l end_time (date +%s%N)
set -l elapsed (math "($end_time - $start_time) / 1000000")
if test $elapsed -gt 100
echo "WARNING: Slow prompt: {$elapsed}ms" >&2
end
end
Best Practices for Debugging Fish Scripts
1. Develop with Debug Mode Active
During development, keep a moderate debug level enabled to catch issues early:
# Add this to the top of scripts during development
set -g fish_debug_level 2
set -g fish_trace 1
2. Use Descriptive Error Messages
Fish provides printf formatting for clear error output:
function error_exit -d "Print error and exit"
set -l func (status current-function)
set -l msg $argv[1]
printf "[%s] ERROR in %s (line %d): %s\n" \
(date +%H:%M:%S) \
"$func" \
(status current-line-number) \
"$msg" >&2
return 1
end
# Usage
if not test -f "$config_file"
error_exit "Config file not found: $config_file"
end
3. Implement Assertions
Build lightweight assertion helpers to catch invalid states:
function assert -d "Assert condition is true"
set -l condition $argv[1]
set -l message $argv[2]
if not eval "$condition"
set -l func (status current-function)
echo "ASSERT FAILED in $func: $message" >&2
echo " Condition: $condition" >&2
breakpoint
return 1
end
end
# Usage examples
assert "test -f /etc/passwd" "System password file must exist"
assert "test \$status -eq 0" "Previous command must succeed"
assert "test -n \"\$USER\"" "USER variable must be set"
4. Log to File for Long-Running Scripts
#!/usr/bin/env fish
# Setup logging for long-running scripts
set -l log_file "/tmp/myscript_"(date +%Y%m%d_%H%M%S)".log"
function log_msg
set -l level $argv[1]
set -l msg $argv[2]
printf "[%s] [%s] %s\n" (date -Iseconds) "$level" "$msg" >> "$log_file"
end
log_msg INFO "Script started"
log_msg DEBUG "Processing file: $argv[1]"
# Redirect debug output to the same log
set -g fish_debug_output "$log_file"
set -g fish_debug_level 3
5. Test Functions in Isolation
# Create test harnesses for individual functions
function test_my_function
set -l expected "Hello, World"
set -l actual (my_function "World")
if test "$actual" = "$expected"
echo "PASS: my_function returned expected value"
else
echo "FAIL: Expected '$expected', got '$actual'" >&2
return 1
end
end
# Run all tests
function run_all_tests
set -l failures 0
for test_func in (functions --all | grep '^test_')
if not $test_func
set failures (math "$failures + 1")
end
end
echo "Tests complete: $failures failures"
return $failures
end
6. Handle Errors Gracefully with status
Always check $status after commands that can fail:
function robust_operation
# Run a command that might fail
set -l output (curl -s "https://api.example.com/data" 2>&1)
set -l exit_code $status
if test $exit_code -ne 0
echo "Curl failed with code $exit_code" >&2
echo "Output: $output" >&2
# Check specific failure modes
if test $exit_code -eq 7
echo "Connection failed — check network" >&2
else if test $exit_code -eq 22
echo "HTTP error — check the URL" >&2
end
return 1
end
# Parse successful response
echo "Success: $output"
end
7. Use type to Resolve Command Ambiguities
When a command behaves unexpectedly, check which version is being invoked:
# Check what 'python' resolves to
type python
# Output might show:
# python is /usr/bin/python3
# Check if it's a function, alias, or external command
type my_command
# Debug path resolution issues
for dir in $PATH
if test -x "$dir/python"
echo "Found python in: $dir"
end
end
8. Profile Performance with Timing
Debug slow scripts by measuring execution time of each section:
function profile_section -d "Time a block of code"
set -l label $argv[1]
set -l start (date +%s%N)
# Execute the rest of the arguments as code
eval $argv[2..-1]
set -l exit_code $status
set -l end (date +%s%N)
set -l elapsed_ms (math "($end - $start) / 1000000")
printf "[PROFILE] %s: %d ms (exit: %d)\n" "$label" $elapsed_ms $exit_code >&2
return $exit_code
end
# Usage
profile_section "Data loading" 'fetch_data "/tmp/input.csv"'
profile_section "Processing" 'process_data'
profile_section "Output generation" 'generate_report > /tmp/report.html"'
Debugging Interactive vs. Script Mode Differences
Fish behaves slightly differently in interactive mode versus script mode. These differences can cause bugs that only appear in one context:
#!/usr/bin/env fish
# Check execution mode
if status is-interactive
echo "Running interactively — some features may differ"
else
echo "Running as script"
end
# Event handlers only work in interactive mode
function on_event --on-event fish_prompt
echo "This only fires in interactive mode"
end
# Debug mode differences
# Script mode: errors cause exit
# Interactive mode: errors are displayed but session continues
# Test both modes explicitly
fish --interactive --debug-level=2 ./script.fish # Force interactive
fish --no-config ./script.fish # Pure script mode
Real-World Debugging Example
Let's walk through debugging a complete, realistic Fish script that processes log files:
#!/usr/bin/env fish
# Script: log_analyzer.fish
# Purpose: Parse server logs and generate statistics
# Bug: Returns empty output for certain log formats
# Enable debugging
set -g fish_debug_level 2
set -g fish_trace 1
function parse_log_line
set -l line $argv[1]
# Bug is here: the regex doesn't account for timestamps with milliseconds
set -l pattern '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (.*)$'
set -l matches (string match --regex --groups-only "$pattern" "$line")
if test -z "$matches"
debug_msg 1 "No match for line: $line"
return
end
set -l timestamp $matches[1]
set -l level $matches[2]
set -l message $matches[3]
echo "$timestamp|$level|$message"
end
function analyze_logs
set -l input_file $argv[1]
if not test -f "$input_file"
error_exit "File not found: $input_file"
end
set -l line_count 0
set -l error_count 0
set -l warn_count 0
while read -l line
set line_count (math "$line_count + 1")
set -l parsed (parse_log_line "$line")
if test -z "$parsed"
debug_msg 2 "Skipping unparseable line $line_count"
continue
end
set -l level (string split "|" "$parsed")[2]
if test "$level" = "ERROR"
set error_count (math "$error_count + 1")
else if test "$level" = "WARN"
set warn_count (math "$warn_count + 1")
end
end < "$input_file"
echo "Lines: $line_count"
echo "Errors: $error_count"
echo "Warnings: $warn_count"
end
# Run the analysis
analyze_logs "/var/log/server.log"
With debug level 2 and trace enabled, the output reveals that lines with millisecond timestamps (like 2024-01-15 14:30:45.123) fail to match the regex pattern. The fix is to update the pattern to '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?) \[(\w+)\] (.*)$'. The debug trace shows exactly which lines are skipped, and the custom debug messages provide context for each failure.
Conclusion
Debugging Fish scripts is a uniquely pleasant experience once you embrace the tools the shell provides. The combination of $fish_trace for execution tracing, breakpoint for interactive state inspection, $fish_debug_level for internal diagnostics, and the status command for stack introspection gives you a complete debugging toolkit that rivals dedicated debuggers in other languages. The key to mastering Fish debugging is to build these tools into your workflow from the start — enable trace output during development, place breakpoints at critical decision points, implement structured logging for long-running scripts, and always check $status after fallible operations. By following the practices outlined in this guide, you'll transform debugging from a frustrating chore into a systematic, efficient process that produces robust, reliable Fish scripts.