| 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 |
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:
sleep 2 & echo "started pid=$!"Sample output:
started pid=23493The 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:
sleep 1 & pid=$!
echo "saved pid=$pid"Sample output:
saved pid=23493If you start a second background command without saving the first PID, $! moves on:
sleep 1 & echo "first=$!"
sleep 2 & echo "last=$!"Sample output:
first=23907
last=23908Only 23908 remains in $! after both lines run. For two parallel checks, capture each PID on the line that starts the job:
sleep 2 & p1=$!
sleep 1 & p2=$!
echo "p1=$p1 p2=$p2"Sample output:
p1=23652 p2=23653Wait 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:
sleep 1 & pid=$!
wait "$pid"
echo "wait exit=$?"Sample output:
wait exit=0To react to failure, test the status wait returns. The || branch runs when the child exits non-zero:
false & pid=$!
wait "$pid" || echo "child failed with $?"Sample output:
child failed with 1wait 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 -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:
sleep 2 & p1=$!
sleep 1 & p2=$!
wait "$p2"
echo "p2 done"
wait "$p1"
echo "p1 done"Sample output:
p2 done
p1 donep2 finished first because its sleep was shorter, even though p1 started first.
You can also pass several PIDs on one wait line:
false & p1=$!
true & p2=$!
wait "$p1" "$p2"
echo "wait status=$?"Sample output:
wait status=0wait "$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:
sleep 1 & sleep 2 & wait
echo "all done exit=$?"Sample output:
all done exit=0That 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:
false &
true &
wait
echo "wait status=$?"Sample output:
wait status=0Use 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:
sleep 1 & s1=$!
sleep 2 & s2=$!
wait "$s1"; e1=$?
wait "$s2"; e2=$?
echo "s1=$e1 s2=$e2"Sample output:
s1=0 s2=0If one child fails, capture its status before moving to the next wait:
false & f=$!
true & t=$!
wait "$f" || echo "fail=$?"
wait "$t"; echo "ok=$?"Sample output:
fail=1
ok=0wait -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:
sleep 3 & p1=$!
sleep 1 & p2=$!
wait -n
echo "first finished"
wait -n
echo "second finished"Sample output:
first finished
second finishedThe 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:
wait -n
echo "exit=$?"Sample output:
exit=127Guard 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:
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:
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:
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]'
doneSample output:
fast finished with status 1
slow finished with status 0fast 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:
value=before
{ value=after; } &
wait
echo "$value"Sample output:
beforeThe 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):
{ echo group-a; sleep 1; } & ga=$!
( echo group-b; sleep 1; ) & gb=$!
wait "$ga"
wait "$gb"
echo "both done"Sample output:
group-a
group-b
both donegroup-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:
printf 'line1\n' > /tmp/bg-stdin-test.txtRun the test in a non-interactive shell so job control stays inactive and stdin is /dev/null for the background read:
bash -c '
read -r line &
pid=$!
wait "$pid"
echo "bg read exit=$? line=${line:-empty}"
'Sample output:
bg read exit=1 line=emptyRedirecting 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:
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.txtSample output:
bg exit=0
line1The 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:
{ 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 -c 'sleep 10 & echo child=$!; exit 0'Sample output:
child=26575After 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:
#!/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.2Run the script in a separate shell:
bash /tmp/background-cleanup.shSample output:
child_pid=25664When 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
- GNU Bash Manual — Lists of Commands —
&, asynchronous commands, and/dev/nullstdin in scripts - GNU Bash Manual — Special Parameters —
$! - GNU Bash Manual — Job Control Builtins —
wait,-n, and-p - GNU Bash NEWS —
wait -pintroduced in Bash 5.1
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.

