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:
and— runs the next command only if the previous one succeeded (exit code 0)or— runs the next command only if the previous one failed (non-zero exit code)
# 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
- Fail loudly and early. Catch errors as close to their source as possible. A failed command at line 5 is easier to debug than a cascade of errors detected at line 200. Use
or fatal_error...immediately after risky operations. - Always redirect error messages to stderr. Use
>&2for diagnostic output. This keeps stdout clean for data pipes and lets callers filter errors separately. Your users will thank you. - Use meaningful exit codes. Don't just return
1for everything. Adopt a convention — for example,1for input errors,2for missing files,3for network failures,4for permission issues. This lets callers handle different failures differently. - Document expected failure modes. In comments or a README, note which exit codes your script or function can return and what they mean. Other developers (including future you) will integrate your code more confidently.
- Use
$pipestatusfor pipelines. Don't assume pipeline failures come from the last command. Check each element of$pipestatuswhen intermediate steps can fail. - Clean up with traps. Any script that creates temporary files, locks, or network connections should register an
EXITtrap for cleanup. Also trapINTandTERMto handle interruptions gracefully. - Distinguish expected from unexpected failures. Not every non-zero exit code is an error.
grep's1,diff's1, and similar "no result" codes should be handled as normal outcomes. Reserve error handling for codes2and above or for commands where any non-zero is truly exceptional. - Log errors with context. Include timestamps, script names, function names, and relevant variable values. A bare "failed" message is nearly useless at 3 AM during an outage. A message like "2025-01-15 03:14:22 FATAL in deploy.fish: API call failed (code 503) — service unavailable" tells you exactly where to look.
- Test error paths. Write tests that simulate failures — missing files, network timeouts, permission denials. It's easy to write the happy path; the error paths are where bugs hide. Use Fish's
--no-configand mock functions to create reproducible failure scenarios. - Keep error handling code readable. Error handling shouldn't obscure the main logic. Extract complex error logic into helper functions like
fatal_errororsafe_run. The main flow should read like a clear narrative, with error checks as concise annotations.
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.