← Back to DevBytes

Fish Scripting: Error Handling Complete Guide

Introduction to Error Handling in Fish

Error handling is the practice of detecting, reporting, and responding to failures in your scripts. In the Fish shell, error handling revolves around exit codes (also called exit statuses) — numeric values that commands return when they finish executing. A command that succeeds returns 0, while any non-zero value (typically 1 through 255) indicates some form of failure.

Fish provides a clean, intuitive syntax for working with these exit codes. Unlike Bash, which relies on cryptic special variables like $? and implicit error propagation, Fish offers a readable $status variable, logical chaining with and / or keywords, and a straightforward approach to trapping signals. This guide covers everything you need to know — from basic status checks to advanced defensive scripting patterns.

Why Error Handling Matters

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Skipping error handling leads to scripts that silently continue after failures, producing incorrect results, corrupting data, or leaving your system in an inconsistent state. Consider a script that deletes temporary files:

# DANGEROUS: no error checking
rm -rf /tmp/cache
cp -r /new/data /important/backup

If the rm command fails because the directory doesn't exist, the script blithely continues to the cp command — which might overwrite backups with stale data or fail in unexpected ways. Proper error handling prevents this cascade of unintended consequences by catching failures early and responding appropriately.

Beyond correctness, error handling improves debuggability. A well-crafted error message with context tells you exactly what went wrong and where, turning hours of puzzling over silent failures into a quick fix.

Understanding Fish's Error Handling Model

Exit Codes and $status

After every command, Fish sets the variable $status to the numeric exit code. You can inspect it directly:

curl -s https://api.example.com/data
echo "Exit code was: $status"

A successful command returns 0. Common failure codes include 1 (general error), 2 (misuse of shell builtins), 126 (command not executable), and 127 (command not found). Commands like grep use 1 to mean "no matches found" — which is technically a failure but often expected behavior. Always check each command's documentation for its specific exit code semantics.

The and and or Operators

Fish uses and and or as first-class keywords (not symbolic operators like && and ||). They chain commands based on the success or failure of the preceding command:

# Download file, then process it only if download succeeded
wget -q https://example.com/file.tar.gz; and tar -xzf file.tar.gz; and echo "Done!"

# Try primary mirror, fall back to secondary on failure
curl -s https://primary.example.com/api; or curl -s https://fallback.example.com/api

Note the semicolon before and and or — it's required. Fish parses command; and next as two separate statements with a conditional connector. You can chain multiple commands:

# Full chain: validate config, build, test, deploy — stop at first failure
fish -n config.fish; and make build; and make test; and make deploy

For capturing output while checking success, combine with begin...end blocks:

begin
    set output (curl -s https://api.example.com 2>&1)
    echo "$output"
end; and echo "API call succeeded"; or echo "API call failed with code $status"

Using if Statements for Error Checking

Fish's if statement evaluates the exit code of the condition command. This is the most readable way to branch on success or failure:

if grep -q "error" /var/log/app.log
    echo "Found errors in log file"
    # trigger alert, send email, etc.
else
    echo "Log file is clean"
end

You can also explicitly check $status with the test command:

curl -s https://api.example.com/data > /tmp/response.json

if test $status -eq 0
    echo "Request succeeded"
else if test $status -eq 404
    echo "Resource not found (404)"
else if test $status -ge 500
    echo "Server error (5xx): code $status"
else
    echo "Unknown failure: code $status"
end

The if block is self-documenting — someone reading your script six months later immediately understands the branching logic.

Practical Error Handling Patterns

Checking Command Success in Functions

Inside Fish functions, you can return custom exit codes using the return builtin. This allows your functions to communicate success or failure to their callers:

function validate_config --argument-names config_path
    if not test -f "$config_path"
        echo "Error: config file not found: $config_path" >&2
        return 1
    end
    
    # Check for required keys
    if not grep -q 'required_key' "$config_path"
        echo "Error: missing required_key in config" >&2
        return 2
    end
    
    echo "Config is valid"
    return 0
end

# Caller checks the function's exit code
validate_config /etc/app/config.yml
if test $status -ne 0
    echo "Config validation failed — aborting" >&2
    exit 1
end

Notice that error messages go to stderr (>&2). This separates diagnostic output from normal stdout, letting callers redirect or suppress errors independently of data output.

Handling Expected "Failures" Gracefully

Some commands use non-zero exit codes for non-error conditions. For example, grep returns 1 when no matches are found. Treat these cases explicitly:

# grep returns 1 for "no match" — handle it gracefully
if grep -q "pattern" some_file.txt
    echo "Pattern found — processing..."
    # do work here
else if test $status -eq 1
    echo "Pattern not found — skipping (this is fine)"
else
    echo "grep encountered an actual error (code $status)" >&2
    exit 1
end

Creating a Try/Catch-like Pattern

Fish doesn't have native try/catch blocks, but you can simulate them using begin...end with status capture:

function try_catch_example
    set -l caught_error false
    set -l error_message ""
    
    begin
        # "try" block — all commands run here
        curl -s https://api.example.com/risky-endpoint > /tmp/response.json
        or begin
            set caught_error true
            set error_message "API call failed with status $status"
        end
        
        if test "$caught_error" = true
            echo "Caught: $error_message" >&2
            # "catch" logic: log, retry, or fallback
            return 1
        end
        
        # Parse the successful response
        set data (cat /tmp/response.json | jq '.results')
        or begin
            set caught_error true
            set error_message "JSON parsing failed"
        end
        
        if test "$caught_error" = true
            echo "Caught: $error_message" >&2
            return 1
        end
    end
    
    echo "Operation completed successfully"
    return 0
end

For a more reusable approach, create a helper function that wraps risky commands:

function safe_run --argument-names description --wrapped
    # Runs a command and handles failure with context
    set -l cmd_output ($argv)
    set -l cmd_status $status
    
    if test $cmd_status -ne 0
        echo "[ERROR] $description failed (code: $cmd_status)" >&2
        echo "  Command: $argv" >&2
        return $cmd_status
    end
    
    echo "$cmd_output"
    return 0
end

# Usage:
safe_run "Fetching user data" curl -s https://api.example.com/users
or begin
    echo "Switching to fallback data source..." >&2
    # fallback logic
end

Trapping Signals for Cleanup

Fish supports the trap builtin for handling signals like SIGINT (Ctrl+C), SIGTERM, and SIGQUIT. This is essential for cleaning up temporary files or releasing resources when a script is interrupted:

function cleanup_temp_files
    echo "Cleaning up temporary files..." >&2
    rm -rf /tmp/my_script_workdir
    echo "Cleanup complete." >&2
end

# Register the trap — runs on INT, TERM, and EXIT
trap cleanup_temp_files INT TERM EXIT

# Create working directory
set workdir /tmp/my_script_workdir
mkdir -p "$workdir"

echo "Starting long-running process... (Ctrl+C to interrupt)"
# Simulate work
for i in (seq 1 10)
    echo "Step $i/10"
    sleep 1
end

echo "Work complete!"
# EXIT trap fires here automatically

The EXIT pseudo-signal fires when the script terminates normally, ensuring cleanup even without interruption. You can list multiple handlers and remove them with trap - SIGNAME.

Error Handling in Pipelines

By default, Fish pipelines report the exit code of the last command. To detect failures earlier in the pipeline, use $pipestatus — an array containing every command's exit code:

# Pipeline: generate data | filter | process
generate_data | grep "valid" | process_results

# $status gives only process_results' exit code
# $pipestatus gives all three: [generate_data_status, grep_status, process_status]

if test $pipestatus[1] -ne 0
    echo "Data generation failed (code $pipestatus[1])" >&2
    exit 1
end

if test $pipestatus[2] -ne 0 -a $pipestatus[2] -ne 1
    # grep returns 1 for "no match" — that's fine
    # But 2+ means a real error (e.g., file not found)
    echo "Filter step failed (code $pipestatus[2])" >&2
    exit 1
end

if test $pipestatus[3] -ne 0
    echo "Processing failed (code $pipestatus[3])" >&2
    exit 1
end

Advanced Error Handling Techniques

Building a Centralized Error Handler

For larger scripts, a dedicated error-handling function keeps your code DRY and ensures consistent error reporting:

function fatal_error --argument-names code message
    # Print a standardized error and exit
    set -l script_name (status current-filename)
    set -l timestamp (date "+%Y-%m-%d %H:%M:%S")
    
    echo "[$timestamp] FATAL in $script_name: $message (exit code: $code)" >&2
    
    # Optional: log to a file
    echo "[$timestamp] FATAL: $message" >> /var/log/myapp/errors.log
    
    exit $code
end

function warn_error --argument-names message
    set -l script_name (status current-filename)
    set -l timestamp (date "+%Y-%m-%d %H:%M:%S")
    
    echo "[$timestamp] WARNING in $script_name: $message" >&2
    echo "[$timestamp] WARNING: $message" >> /var/log/myapp/errors.log
end

# Usage throughout the script:
if not test -f config.json
    fatal_error 1 "Configuration file config.json not found"
end

set data (curl -s https://api.example.com)
or fatal_error 3 "Failed to fetch data from API"

test -n "$data"; or fatal_error 4 "API returned empty response"

Defensive Programming with Pre-condition Checks

Validate all assumptions at the start of your script or function. This catches misconfigurations before any work begins:

function process_batch --argument-names input_dir output_dir
    # Pre-condition: verify all inputs
    if test -z "$input_dir"
        echo "Usage: process_batch INPUT_DIR OUTPUT_DIR" >&2
        return 1
    end
    
    if not test -d "$input_dir"
        echo "Error: input directory does not exist: $input_dir" >&2
        return 2
    end
    
    if not test -r "$input_dir"
        echo "Error: input directory not readable: $input_dir" >&2
        return 3
    end
    
    if test -z "$output_dir"
        echo "Error: output directory not specified" >&2
        return 1
    end
    
    # Ensure output directory exists
    mkdir -p "$output_dir"
    or begin
        echo "Error: cannot create output directory: $output_dir" >&2
        return 4
    end
    
    # Check available disk space (need at least 500MB)
    set -l available_kb (df "$output_dir" | awk 'NR>1 {print $4}')
    if test "$available_kb" -lt 512000
        echo "Error: insufficient disk space (need 500MB, have $available_kb KB)" >&2
        return 5
    end
    
    echo "All checks passed — starting batch processing..."
    # Actual work begins here
    return 0
end

Retry Logic with Exponential Backoff

Transient failures (network timeouts, temporary service unavailability) benefit from automatic retries:

function retry_command --argument-names max_retries base_delay_seconds --wrapped
    set -l attempt 0
    
    while test $attempt -lt $max_retries
        set attempt (math $attempt + 1)
        
        # Execute the wrapped command
        $argv
        set -l result_status $status
        
        if test $result_status -eq 0
            echo "Success on attempt $attempt" >&2
            return 0
        end
        
        echo "Attempt $attempt failed (code: $result_status)" >&2
        
        if test $attempt -lt $max_retries
            # Exponential backoff with jitter
            set -l delay (math "$base_delay_seconds * (2 ^ ($attempt - 1))")
            set -l jitter (math "floor($delay * 0.2 * (random 0 100) / 100)")
            set -l total_delay (math "$delay + $jitter")
            
            echo "Retrying in $total_delay seconds..." >&2
            sleep $total_delay
        end
    end
    
    echo "All $max_retries attempts failed — giving up" >&2
    return 1
end

# Usage: retry an API call up to 5 times with 1-second base delay
retry_command 5 1 curl -s --connect-timeout 10 https://flaky-api.example.com/data
or fatal_error 5 "API unreachable after multiple retries"

Handling Background Jobs

Fish manages background processes with its job system. When a background job fails, the error isn't immediately visible. Use job control and explicit checks:

# Launch a background task
begin
    sleep 5
    echo "Background task finished"
end &

set -l bg_pid $last_pid
echo "Background job PID: $bg_pid — doing other work..."

# Wait for the background job to complete
wait $bg_pid
set -l bg_status $status

if test $bg_status -ne 0
    echo "Background job failed with exit code $bg_status" >&2
    # Handle the failure
else
    echo "Background job completed successfully"
end

Best Practices for Error Handling in Fish

Conclusion

Error handling in Fish shell scripting combines simplicity with power. The $status variable, and/or chaining, and if statements give you an expressive toolkit that reads naturally and catches failures reliably. By adopting the patterns in this guide — centralized error functions, defensive pre-condition checks, retry logic, signal trapping, and thoughtful exit code conventions — you'll write scripts that fail gracefully, communicate problems clearly, and protect your systems from cascading failures.

The key takeaway: treat error handling as a first-class design concern, not an afterthought. Every command that can fail will fail someday — in production, at scale, under load. Your scripts should be ready for that day. Start with $status, build up to fatal_error helpers and EXIT traps, and you'll have robust, maintainable Fish scripts that earn the trust of anyone who depends on them.

🚀 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