← Back to DevBytes

Fish Scripting: Debugging Scripts Complete Guide

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:

Why Debugging Fish Scripts Matters

Even the most carefully written Fish scripts can fail. Understanding debugging techniques matters because:

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.

🚀 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