← Back to DevBytes

Fish Scripting: Job Control Complete Guide

Introduction to Job Control in Fish Shell

Job control is a fundamental feature of Unix shells that allows you to manage multiple processes simultaneously within a single terminal session. Fish shell (the Friendly Interactive SHell) implements job control with a refreshingly intuitive syntax that makes process management feel natural and discoverable. Unlike other shells that require memorizing obscure key combinations, Fish surfaces job information clearly and provides straightforward commands for interacting with background processes.

At its core, job control in Fish revolves around three essential operations: suspending running tasks, sending them to the background, and bringing them back to the foreground when needed. This capability transforms your terminal from a single-threaded command executor into a multi-tasking powerhouse, enabling workflows where you can edit code while keeping a server running, or pause a long computation to check system resources before resuming it.

What Is Job Control and Why It Matters

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Job control refers to the shell's ability to start, stop, resume, and manage multiple processes—called jobs—that share the same terminal. Each job is assigned a unique numeric identifier and can exist in one of three states: running in the foreground, running in the background, or suspended (stopped). Fish tracks these jobs automatically and provides built-in commands to manipulate them.

Understanding job control matters for several practical reasons:

Fish distinguishes itself by presenting job information in a human-readable format and using descriptive command names like bg and fg rather than cryptic signals or PID manipulation alone.

Job States and Lifecycle

Every job managed by Fish transitions through a well-defined lifecycle. Understanding these states is essential for effective job control scripting:

How Fish Tracks Jobs Internally

Fish maintains a job table that maps each job to a unique integer ID. When you start a process in the background or suspend a foreground process, Fish registers it in this table. You can inspect the current job list at any time using the jobs command. Fish also tracks the process group ID (PGID) for each job, which allows it to send signals to the entire process tree—crucial when a command spawns child processes.

# Start three background jobs to see the job table in action
sleep 120 &
sleep 240 &
sleep 360 &

# Display all tracked jobs
jobs
# Output:
# Job 1: 'sleep 120 &'  running
# Job 2: 'sleep 240 &'  running
# Job 3: 'sleep 360 &'  running

Starting Background Jobs

The most common entry point for job control is launching a command directly in the background. In Fish, you append an ampersand (&) to the end of a command line. Fish immediately assigns the job an ID, prints that ID along with the process ID of the lead process, and returns control to the shell prompt.

Basic Background Launch Syntax

# Launch a command in the background
sleep 30 &

# Fish responds with something like:
# Job 1: 'sleep 30 &' has PID 12345 running in the background
# Type 'fg 1' to bring it to the foreground, or check 'jobs' for status

Fish goes beyond basic backgrounding by offering the fish -c pattern and the ability to background entire pipelines. When you background a pipeline, Fish treats the entire sequence as a single job:

# Background a pipeline—all processes become one job
cat large_file.txt | grep "error" | sort > errors.txt &

# The entire pipeline is job-controlled as a unit
# Use 'jobs' to see the single job entry

Backgrounding with Output Management

Background jobs inherit the terminal's standard output and standard error by default, which means their output can interleave with your interactive shell session. To prevent this, redirect output explicitly:

# Redirect both stdout and stderr to avoid terminal clutter
./long_running_script.sh > output.log 2>&1 &

# For completely silent background jobs
find / -name "*.tmp" > /dev/null 2>&1 &

Suspending and Manipulating Foreground Jobs

While a command runs in the foreground, you can suspend it by pressing Ctrl+Z (the terminal sends SIGTSTP). Fish catches this signal, pauses the job, and returns you to the prompt with a clear message. The suspended job retains all its state—memory, file handles, network connections—exactly as they were at the moment of suspension.

Suspending Interactive Programs

# Start a text editor
nano important_file.txt

# Press Ctrl+Z while nano is running
# Fish prints:
# Send job 1, 'nano important_file.txt' to background, or run 'fg 1' to bring it back

# The editor is paused; you can now run other commands
ls -la
cat another_file.txt

# Resume nano exactly where you left off
fg 1

Suspending Long-Running Computations

# Start a data processing script
python3 process_dataset.py --input data.csv

# Press Ctrl+Z to suspend mid-computation
# Fish: Job 1, 'python3 process_dataset.py …' stopped

# Check system resources while the job is paused
htop
free -h

# Resume processing
fg

The bg Command: Resume in Background

When a job is suspended (stopped state), you can resume its execution in the background using the bg command. This is particularly useful when you realize a paused foreground task doesn't require your immediate attention and can quietly complete while you do other work. The job continues running without occupying the terminal.

Basic bg Usage

# Suspend a running job with Ctrl+Z
sleep 120
# Ctrl+Z pressed
# Job 1, 'sleep 120' stopped

# Resume job 1 in the background
bg 1
# Fish: Continue job 1, 'sleep 120' running in background

# Or resume the most recently suspended job without specifying an ID
bg

bg with Job Selection

Fish supports several ways to specify which job to background. If you omit the job ID, bg acts on the most recently suspended job. You can also reference jobs by their process ID or by a substring of the command:

# Reference by job ID (most precise)
bg 3

# Reference by process ID
bg -p 12345

# Reference by command substring
bg -c "python3"
# This backgrounds the most recent stopped job whose command contains "python3"

The fg Command: Bring Jobs to Foreground

The fg command is the counterpart to bg. It brings a background or suspended job into the foreground, reconnecting it to the terminal for input and output. This is how you resume interactive programs or inspect the progress of a background task that needs your attention.

Bringing Suspended Jobs Forward

# Resume a stopped job in the foreground
fg 1

# Resume the most recently stopped or backgrounded job
fg

Bringing Running Background Jobs Forward

# Start a job in the background
./monitoring_daemon.sh &

# Later, bring it to the foreground to interact with it
fg 1
# Job now occupies the terminal; you can send input or Ctrl+C to stop it

fg with Job Selection Options

# Bring job by ID
fg 2

# Bring job by PID
fg -p 9876

# Bring job by command substring match
fg -c "top"
# Brings the most recent job whose command contains "top" to foreground

The jobs Command: Inspecting Job State

The jobs command is your window into the current state of all managed jobs. It lists every job Fish is tracking, along with its ID, command string, and execution state. This command is indispensable when you've accumulated multiple background and suspended tasks and need to recall what each one is doing.

Basic jobs Output

jobs

# Typical output:
# Job 1: 'sleep 120 &'  running
# Job 2: 'python3 train_model.py &'  running
# Job 3: 'nano notes.txt'  stopped
# Job 4: 'find / -name "*.log" > logs.txt &'  running

Filtering jobs Output

Fish allows you to filter the jobs listing to show only jobs in a particular state, or to show detailed process information:

# Show only stopped (suspended) jobs
jobs -s
# Output: Job 3: 'nano notes.txt'  stopped

# Show only running jobs
jobs -r

# Show process IDs in addition to job IDs
jobs -p
# Output includes PIDs for each process in the job's process group

# Combine flags: show PIDs of stopped jobs only
jobs -s -p

Job Control in Fish Scripts

While job control is predominantly an interactive feature, Fish scripts can leverage background jobs and job management for parallel execution patterns. This is especially valuable for build scripts, deployment automation, and test runners that benefit from concurrent task execution.

Launching Parallel Tasks in Scripts

#!/usr/bin/env fish

# Run three independent tasks concurrently
./task_a.sh &
set job_a_id $last_job_id

./task_b.sh &
set job_b_id $last_job_id

./task_c.sh &
set job_c_id $last_job_id

# Wait for all three to complete
wait $job_a_id $job_b_id $job_c_id

echo "All tasks completed"

Using the $last_job_id Variable

Fish automatically sets the special variable $last_job_id after you start a background job. This is extremely useful in scripts where you need to track a job for later waiting or status checks:

# Start a background task and capture its job ID
./long_build.sh &
set build_job $last_job_id

# Do other work while the build runs
echo "Build started in job $build_job"
./run_tests.sh

# Wait for the build to finish before proceeding
wait $build_job
echo "Build completed with status $status"

Checking Job Completion Status

When you use wait to block until a job finishes, Fish sets the $status variable to the exit code of the waited job. This allows scripts to handle success and failure paths appropriately:

./critical_task.sh &
set critical_job $last_job_id

# Wait and check exit status
wait $critical_job
if test $status -eq 0
    echo "Critical task succeeded"
else
    echo "Critical task failed with exit code $status" >&2
    exit 1
end

Disowning Jobs: Detaching from Shell Control

Sometimes you want a background job to continue running even after you close the shell session. The disown command removes a job from Fish's job table, allowing it to run independently. Once disowned, you can no longer use fg, bg, or jobs to manage it, but the process continues executing.

Basic Disown Usage

# Start a long-running server
./start_server.sh &

# Disown job 1 so it survives shell exit
disown 1

# Verify it's removed from job tracking
jobs
# Job 1 no longer appears

# The server process continues running even after you close the terminal

Disowning with Process ID

# Disown by PID instead of job ID
disown -p 12345

# Disown the most recently backgrounded job
disown

Disown in Scripts for Fire-and-Forget Tasks

#!/usr/bin/env fish

# Start a daemon that should persist beyond script execution
./daemon_process.sh &
disown $last_job_id

echo "Daemon launched and disowned—it will continue running"
# Script can now exit without affecting the daemon

Advanced Job Control Patterns

Job Chains with Conditional Execution

You can combine job control with Fish's conditional execution operators to create sophisticated workflows. Background a job and use wait with conditionals to sequence dependent tasks:

#!/usr/bin/env fish

# Stage 1: Data collection (background)
./collect_data.sh > data.csv &
set collect_job $last_job_id

# Stage 2: Start preprocessing while collection runs
# This job will wait if it needs the full dataset
./preprocess.sh &

# Wait for collection to finish
wait $collect_job
if test $status -ne 0
    echo "Data collection failed" >&2
    exit 1
end

# Now run analysis that depends on both prior stages
./analyze.sh data.csv

Timeout Patterns with Background Jobs

A common scripting pattern uses background jobs to implement timeouts. Launch the main task in the background, start a sleep timer in the background, and wait for whichever finishes first:

#!/usr/bin/env fish

function run_with_timeout -a timeout_seconds command
    # Start the actual command in background
    eval $command &
    set cmd_job $last_job_id

    # Start a timeout sleeper in background
    sleep $timeout_seconds &
    set timer_job $last_job_id

    # Wait for whichever job finishes first
    wait --any $cmd_job $timer_job

    # Check if timer finished first (command still running)
    jobs -r | grep -q "Job $timer_job"
    if test $status -eq 0
        echo "Command timed out after $timeout_seconds seconds" >&2
        kill (jobs -p $cmd_job)
        return 1
    end

    wait $cmd_job
    return $status
end

# Usage
run_with_timeout 30 "python3 slow_script.py"

Managing Multiple Workers with Job Arrays

For parallel processing tasks, you can launch multiple background workers and track them using Fish arrays. This pattern is useful for batch processing, parallel API calls, or concurrent file operations:

#!/usr/bin/env fish

set worker_jobs
set files (find ./data -name "*.json")

for file in $files
    process_file.sh $file &
    set -a worker_jobs $last_job_id
end

echo "Launched $(count $worker_jobs) parallel workers"

# Wait for all workers to complete
for job in $worker_jobs
    wait $job
    echo "Job $job finished with status $status"
end

echo "All files processed"

Signal Handling and Job Control

Under the hood, job control relies on Unix signals. Fish translates user actions (Ctrl+Z, bg, fg) into appropriate signals sent to process groups. Understanding this mapping helps you debug job control behavior and write scripts that interact correctly with the signal system.

Key Signals for Job Control

Killing Jobs with Signals

Fish provides the kill builtin that can target jobs by their job ID or process group. This is more convenient than manually looking up PIDs:

# Gracefully terminate job 2
kill 2

# Force-kill job 2 with SIGKILL
kill -9 2

# Send SIGTERM to a job by process ID
kill -p 12345

# Send a custom signal to a job
kill -s SIGUSR1 3

Trapping Signals in Fish Scripts

Fish's trap command lets you define handlers for signals, which is particularly useful for cleanup when jobs are terminated. This works differently from POSIX shells—Fish uses an event-based model:

#!/usr/bin/env fish

function on_sigint --on-signal INT
    echo "Caught SIGINT—cleaning up background jobs"
    jobs -p | xargs kill -9 2>/dev/null
    exit 1
end

function on_sigterm --on-signal TERM
    echo "Received termination signal—shutting down gracefully"
    # Perform cleanup logic here
    exit 0
end

# Main script logic with background jobs
./long_task.sh &
wait $last_job_id

Job Control and Terminal Multiplexing

Fish's job control can serve as a lightweight alternative to terminal multiplexers like tmux or screen for certain workflows. By suspending interactive programs and switching between them with fg, you can manage multiple full-screen terminal applications from a single shell.

Switching Between Editors and Shell

# Open editor
vim src/main.rs

# Ctrl+Z to suspend vim
# Now at shell prompt

# Run tests
cargo test

# Return to vim
fg

# Make edits, then Ctrl+Z again to check build
cargo build

# Resume vim again
fg

Managing Multiple REPLs

# Start Python REPL
python3

# Suspend with Ctrl+Z
# Job 1: python3 stopped

# Start Node.js REPL
node

# Suspend with Ctrl+Z
# Job 2: node stopped

# Start a database CLI
psql -d mydatabase

# Suspend with Ctrl+Z
# Job 3: psql stopped

# Now switch between them as needed
fg 1   # Back to Python
# Ctrl+Z
fg 2   # Back to Node.js
# Ctrl+Z
fg 3   # Back to PostgreSQL

Best Practices for Fish Job Control

1. Always Redirect Output for Background Jobs

Background jobs that print to stdout or stderr can create confusing interleaved output in your terminal. Make it a habit to redirect output unless you specifically need to see it intermixed:

# Good: output is captured and won't clutter the terminal
./build.sh > build.log 2>&1 &

# Also good: explicitly discard unwanted output
./noisy_task.sh > /dev/null 2>&1 &

2. Use wait for Script Synchronization

When your script launches parallel background jobs, always use wait to collect them before proceeding with dependent work. This prevents race conditions where subsequent commands execute before background tasks complete:

# Launch parallel downloads
curl -O url1 &
set job1 $last_job_id
curl -O url2 &
set job2 $last_job_id

# Wait for both before processing
wait $job1 $job2
echo "Downloads complete—starting processing"

3. Check $status After wait

The wait command sets $status to the exit code of the last job it waited for. Always inspect this value to handle failures gracefully:

./critical_task.sh &
set job_id $last_job_id

wait $job_id
if test $status -ne 0
    echo "Task failed—aborting" >&2
    exit 1
end

4. Disown Persistent Services

For daemons, servers, or any process that should outlive your shell session, use disown. This prevents Fish from terminating the job when the shell exits and avoids SIGHUP being delivered:

# Start a development server and detach it from the shell
./dev_server.sh > server.log 2>&1 &
disown

5. Use Descriptive Job References

Instead of relying solely on numeric job IDs (which can change as jobs complete), use the -c flag with fg and bg to reference jobs by command substring. This makes interactive use more intuitive:

fg -c "python3"
bg -c "find"
jobs -c "sleep"

6. Clean Up Stopped Jobs Before Exiting

Fish warns you if you try to exit with stopped jobs. Rather than ignoring this, either resume them with bg or fg, or explicitly kill them:

# Before exiting, handle stopped jobs
jobs -s
# If you see stopped jobs you no longer need:
kill -9 (jobs -s -p)

7. Leverage $last_job_id Immediately

The $last_job_id variable is set only right after launching a background job. Capture it immediately to avoid losing the reference:

# Correct: capture immediately
./task.sh &
set task_job $last_job_id

# Incorrect: another command might modify $last_job_id
./task.sh &
echo "Just launched a job"  # This doesn't change it, but don't rely on that
set task_job $last_job_id   # Still safe here, but fragile in complex scripts

8. Test Job Control in Interactive Sessions First

Job control behavior can vary based on terminal settings, process group leadership, and signal masking. Before embedding complex job control logic in scripts, test the patterns interactively to ensure they work as expected in your environment:

# Test interactively
sleep 30 &
wait $last_job_id
echo "Exit status: $status"

# Then script it with confidence

Common Pitfalls and Troubleshooting

Jobs Not Appearing in jobs Output

If a background job doesn't show up in jobs, it may have already completed, or it may not have been launched with Fish's job control syntax. Commands executed via exec or those that fork and disown themselves internally won't be tracked.

Suspended Jobs Blocking Shell Exit

Fish prevents exiting when stopped jobs exist. This is a safety feature to prevent accidentally losing work. You'll see: There are stopped jobs. Use 'jobs' to see them. Either resume them or use disown/kill to resolve them before exiting.

Background Job Output Interference

Background jobs inherit the terminal's stdout/stderr. Their output appears inline with your prompt, which can be confusing. The solution is redirection, as discussed in Best Practice #1. If output has already appeared, pressing Enter typically redisplays a clean prompt.

wait Returning Unexpected Status

When using wait with multiple job IDs, $status reflects the exit code of the last job waited for. To track individual exit codes, use separate wait calls and capture $status after each:

./job1.sh &
set j1 $last_job_id
./job2.sh &
set j2 $last_job_id

wait $j1
set j1_status $status
wait $j2
set j2_status $status

echo "Job 1 exited with $j1_status"
echo "Job 2 exited with $j2_status"

Processes Surviving Shell Exit Unintentionally

If you notice processes still running after closing your shell, they were likely disowned or launched with nohup. Use ps to find and manually kill them if needed. To prevent this, avoid disown unless persistence is explicitly desired.

Comparison with Other Shells

Fish's job control implementation differs from POSIX shells like bash and zsh in several notable ways that reduce cognitive load:

These design choices reflect Fish's philosophy of making shell features discoverable and consistent, which is especially valuable for job control—a feature that many users learn through trial and error.

Conclusion

Fish shell's job control system transforms the terminal into a capable multitasking environment without requiring external tools. By mastering the core commands—& for background launch, Ctrl+Z for suspension, bg and fg for state transitions, jobs for inspection, wait for synchronization, and disown for detachment—you gain precise control over process lifecycles that enhances both interactive workflows and shell scripting.

The key to effective job control is developing habits around output management, systematic job tracking with $last_job_id, and graceful error handling through $status checks. Fish's thoughtful defaults and clear feedback messages make these practices easy to adopt. Whether you're orchestrating parallel build steps in a script, juggling multiple interactive sessions, or building a custom timeout mechanism, Fish's job control primitives provide a robust foundation that scales from simple one-off commands to sophisticated process orchestration patterns.

🚀 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