Run Bash Commands in Background: &, $! and wait

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
Applies to Linux distributions with Bash installed
Privilege Normal user
Scope Script-centric background children: &, $!, wait, wait -n, wait -p, grouped { ...; } & and ( ... ) &, I/O redirection, exit-status collection, and EXIT/TERM cleanup. Does not cover interactive job control (fg, bg, jobs) in depth or long-running daemon setup.
Related guides Shell scripting course hub
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

In this article we will run Bash commands in the background, save their process IDs, and collect exit statuses with wait. The patterns here target scripts that overlap slow checks or fan out work, not an interactive terminal where you juggle jobs with fg and bg.

A background child keeps running while the parent script continues. That overlap is useful, but you must store each PID as soon as it starts, wait before you read results, and trap cleanup when the script exits early. Each section uses a short reproducible example.


Run a Command in Background with &

Append & to a simple command or pipeline to start it asynchronously. Bash prints nothing for the background job itself; control returns to the shell immediately:

bash
sleep 2 & echo "started pid=$!"

Sample output:

output
started pid=23493

The line prints right away while sleep runs for two seconds in the background. Without &, the same sleep would block the script until it finished.


Capture Its PID with $!

$! expands to the process ID of the most recent background command started in the current shell. Save it into a variable before you launch another background job:

bash
sleep 1 & pid=$!
echo "saved pid=$pid"

Sample output:

output
saved pid=23493

If you start a second background command without saving the first PID, $! moves on:

bash
sleep 1 & echo "first=$!"
sleep 2 & echo "last=$!"

Sample output:

output
first=23907
last=23908

Only 23908 remains in $! after both lines run. For two parallel checks, capture each PID on the line that starts the job:

bash
sleep 2 & p1=$!
sleep 1 & p2=$!
echo "p1=$p1 p2=$p2"

Sample output:

output
p1=23652 p2=23653

Wait for One Child and Get Its Exit Status

wait with a PID blocks until that child exits and returns the child's exit status. A zero means success; a non-zero value means the command failed:

bash
sleep 1 & pid=$!
wait "$pid"
echo "wait exit=$?"

Sample output:

output
wait exit=0

To react to failure, test the status wait returns. The || branch runs when the child exits non-zero:

bash
false & pid=$!
wait "$pid" || echo "child failed with $?"

Sample output:

output
child failed with 1

wait sets the same status you would see from $? immediately after running the command in the foreground. For a fuller reference on status codes, see Bash exit codes.

With Bash strict mode (set -e), a bare wait "$pid" can terminate the script when the child fails:

bash
bash -c 'set -e; false & pid=$!; wait "$pid"; echo "never reached"'

The subshell exits before never reached prints because wait returned status 1. Put wait in an if/else or another explicitly handled conditional when you need to inspect a non-zero child status under set -e.


Wait for Multiple Children

When you know every PID up front, wait for them one at a time or pass several IDs on one wait line:

bash
sleep 2 & p1=$!
sleep 1 & p2=$!
wait "$p2"
echo "p2 done"
wait "$p1"
echo "p1 done"

Sample output:

output
p2 done
p1 done

p2 finished first because its sleep was shorter, even though p1 started first.

You can also pass several PIDs on one wait line:

bash
false & p1=$!
true & p2=$!
wait "$p1" "$p2"
echo "wait status=$?"

Sample output:

output
wait status=0

wait "$p1" "$p2" waits for both children, but its return status is the status associated with the last ID on the line ($p2 here), not a combination of both exit codes.

wait with no arguments waits for all outstanding background children and returns zero when they have all exited:

bash
sleep 1 & sleep 2 & wait
echo "all done exit=$?"

Sample output:

output
all done exit=0

That form is fine when you only need to know that every child finished. It does not tell you which child failed first or let you attribute one status per task without extra bookkeeping.

A bare wait does not aggregate child failures. Even when one child exits non-zero, wait with no arguments still returns zero:

bash
false &
true &
wait
echo "wait status=$?"

Sample output:

output
wait status=0

Use a bare wait for synchronization only. Save PIDs and wait "$pid" on each child when success or failure per task matters.

To collect per-child statuses, wait on each saved PID and store the result:

bash
sleep 1 & s1=$!
sleep 2 & s2=$!
wait "$s1"; e1=$?
wait "$s2"; e2=$?
echo "s1=$e1 s2=$e2"

Sample output:

output
s1=0 s2=0

If one child fails, capture its status before moving to the next wait:

bash
false & f=$!
true & t=$!
wait "$f" || echo "fail=$?"
wait "$t"; echo "ok=$?"

Sample output:

output
fail=1
ok=0

wait -n: React to the Next Completed Job

wait -n blocks until any one unwaited background child exits. Use it when tasks finish in unpredictable order and you want to react as each one completes:

bash
sleep 3 & p1=$!
sleep 1 & p2=$!
wait -n
echo "first finished"
wait -n
echo "second finished"

Sample output:

output
first finished
second finished

The shorter sleep child finishes on the first wait -n, even though it was started second.

If there are no unwaited children, wait -n fails with exit status 127:

bash
wait -n
echo "exit=$?"

Sample output:

output
exit=127

Guard loops that call wait -n so you do not spin on an empty child list.


Identify the Completed Job with wait -p

Bash 5.1 and later support wait -n -p var. With wait -n -p var, Bash stores the process or job identifier whose status was returned in var. For the background processes used in these script examples, that value is the child PID:

bash
sleep 3 & p1=$!
sleep 1 & p2=$!
wait -n -p done
echo "done pid=$done (expected $p2)"
wait -n -p done
echo "done pid=$done (expected $p1)"

Sample output:

output
done pid=23857 (expected 23857)
done pid=23856 (expected 23856)

The variable is unset before the first assignment, then updated on each wait -n -p call.

A practical pattern maps each PID to a task name and handles jobs as they finish:

bash
declare -A names
( sleep 2; exit 0 ) & names[$!]=slow
( sleep 0.5; exit 1 ) & names[$!]=fast
while ((${#names[@]} > 0)); do
  done_pid=
  if wait -n -p done_pid; then
    status=0
  else
    status=$?
  fi
  if [[ -z ${done_pid:-} ]]; then
    echo "no unwaited children remain" >&2
    break
  fi
  echo "${names[$done_pid]} finished with status $status"
  unset 'names[$done_pid]'
done

Sample output:

output
fast finished with status 1
slow finished with status 0

fast completed first with exit status 1. The if wait -n -p branch records status=0 on success and status=$? when the child failed, so a failing task does not look like a wait error. The loop exits when the associative array is empty.


Background a Command Group

& applies to the preceding simple command or pipeline. To background several commands as one unit, wrap them in a group.

Braces normally run in the current shell, while parentheses explicitly create a Bash subshell. Once either group is backgrounded with &, Bash executes the asynchronous group in a subshell environment. Variable changes inside either background group do not propagate back to the parent script:

bash
value=before
{ value=after; } &
wait
echo "$value"

Sample output:

output
before

The same isolation applies to ( ... ) &. Background execution is what creates the subshell boundary, not the choice of braces versus parentheses.

Start one of each and wait for both by PID. $! holds the PID of the job most recently placed in the background (the whole group, not an individual command inside it):

bash
{ echo group-a; sleep 1; } & ga=$!
( echo group-b; sleep 1; ) & gb=$!
wait "$ga"
wait "$gb"
echo "both done"

Sample output:

output
group-a
group-b
both done

group-a and group-b may appear in either order because the groups run concurrently; only both done is guaranteed to print after both wait calls complete.

Use braces or parentheses to group commands and capture one $! for the unit. Do not expect assignments inside a background group to update parent variables.


stdin, stdout, and stderr for Background Jobs

Background commands inherit stdout and stderr unless you redirect them. In a non-interactive Bash script with job control disabled, a background command without explicit stdin redirection gets stdin from /dev/null.

Create a one-line input file for a quick test:

bash
printf 'line1\n' > /tmp/bg-stdin-test.txt

Run the test in a non-interactive shell so job control stays inactive and stdin is /dev/null for the background read:

bash
bash -c '
read -r line &
pid=$!
wait "$pid"
echo "bg read exit=$? line=${line:-empty}"
'

Sample output:

output
bg read exit=1 line=empty

Redirecting stdin lets the background child read a file, but variables assigned inside a background command do not propagate back to the parent shell. Demonstrate I/O with files instead of trying to return a value in a parent variable:

bash
cat < /tmp/bg-stdin-test.txt > /tmp/bg-result.txt &
pid=$!
wait "$pid"
status=$?
echo "bg exit=$status"
cat /tmp/bg-result.txt
rm -f /tmp/bg-stdin-test.txt /tmp/bg-result.txt

Sample output:

output
bg exit=0
line1

The child read from the redirected input and wrote line1 to the result file. Use files, pipes, FIFOs, or other IPC when a background child must return data to the parent. See Bash redirection for file descriptors and here-strings.

Send stdout and stderr to log files when background noise would clutter the parent's terminal:

bash
{ long_running_task; } >task.log 2>&1 &

Clean Up Background Children on Exit

Background children do not always stop when the parent script reaches the end. This one-liner starts sleep in the background and exits immediately; the child can keep running:

bash
bash -c 'sleep 10 & echo child=$!; exit 0'

Sample output:

output
child=26575

After the parent exits, check with ps if you need to confirm the child is still alive on your system. Do not assume every background job dies with the script.

Register an EXIT trap plus INT and TERM handlers so interruption still runs cleanup. The pattern matches Bash trap cleanup for temp files, applied to PIDs. Save the following as /tmp/background-cleanup.sh so EXIT refers to the script ending, not your interactive shell:

bash
#!/usr/bin/env bash
child_pid=
cleanup() {
  if [[ -n $child_pid ]]; then
    kill "$child_pid" 2>/dev/null
    wait "$child_pid" 2>/dev/null
  fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
sleep 60 &
child_pid=$!
echo "child_pid=$child_pid"
sleep 0.2

Run the script in a separate shell:

bash
bash /tmp/background-cleanup.sh

Sample output:

output
child_pid=25664

When the script exits or receives SIGINT/SIGTERM, the trap sends SIGTERM to the saved child and waits so it does not become an orphan sleep. Killing the saved PID terminates that child; it does not necessarily terminate grandchildren the child spawned.

For multiple children, loop over a children array and use kill and pkill on each saved PID before a final wait.


Backgrounding vs nohup vs Interactive Job Control

These tools solve different problems:

Mechanism Typical use in scripts
command & Overlap work inside a script; parent stays in control with wait
nohup command & Survive terminal hangup when a human started the job from an SSH session
disown Remove a job from the shell's job table after the fact
jobs, fg, bg Interactive session job control, not the usual model for batch scripts

For deploy scripts and health checks, prefer explicit PIDs, wait, and traps. Reach for nohup when a human needs a long command to outlive a logout, not as the default inside structured Bash automation.


Common PID and Race Mistakes

Mistake What goes wrong Fix
Assume wait with no args gives per-task status Only know that all children finished wait "$pid" per child or use wait -n -p
Call wait -n with no children Exit 127 Track outstanding PIDs or break the loop
Background read without redirect stdin is /dev/null in scripts; read fails Redirect < file or use a pipe
Expect parent variables from command & Assignments stay in the async subshell Use files, pipes, or FIFOs for IPC
Treat wait -n failure as wait error A child exit 1 makes wait -n return 1 Capture status=$? separately from PID via -p
Exit the script without wait or trap Orphan processes, wrong exit status wait required children; trap EXIT to kill stragglers
Launch another job before saving $! $! now refers to the newer background job Assign pid=$! immediately after every &

Summary

Background execution with & lets a Bash script overlap slow commands while the parent keeps running. Save each child's PID in $! right when you start the job, because later background commands overwrite that special parameter.

wait "$pid" blocks for one child and returns its exit status. wait with no arguments waits for every outstanding child but does not attribute failures to individual tasks. When finish order matters, wait -n reacts to the next completion and wait -n -p var records which child completed.

Group related commands with { ...; } & or ( ... ) &, remembering that background groups run in a subshell and cannot update parent variables. Redirect stdin and logs explicitly, and trap EXIT, INT, and TERM to terminate children when the script ends. Interactive jobs/fg/bg and nohup address terminal sessions and hangups; script automation should lean on stored PIDs, wait, and cleanup traps instead.

A common mistake is assuming $! keeps referring to an earlier background job. It only reflects the last job placed in the background. Pair immediate PID capture with explicit wait or wait -n -p loops, and you can run parallel health checks or staged deploy steps without losing track of which child failed.


References


Frequently Asked Questions

What does $! mean?

$! is the process ID of the last command you started in the background in the current shell. It updates every time you append & to a new command, so copy it into a named variable immediately if you will need it later.

How do I get a background process exit code?

Run wait "$pid" after the child finishes. The exit status of wait is the child's exit status. You can also use wait -n when you do not care which child finished first, as long as at least one unwaited child exists.

What does wait -n do?

wait -n blocks until any one background child that has not been waited for yet exits, then returns that child's exit status. If no such children exist, wait -n fails with status 127.

How do I know which job finished?

Use wait -n -p var in Bash 5.1 and later. Bash assigns the process or job identifier whose status was returned to var. For simple background children in scripts, var contains the PID that completed. Pair that with an associative array or parallel arrays of PID and task name to label each completion.

Does a background process stop when its parent script exits?

Not always. A child started with & may keep running after the parent shell exits unless you wait for it, trap EXIT to kill it, or start it under a process supervisor. Scripts that spawn background work should trap cleanup or explicitly wait before exiting.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)