Bash Scripting 15 ๐ง Return Values โ Handling exit status codes ($?) and function outputs
Every command in Bash returns a number between 0 and 255 when it finishes. That number is the exit status, and it is the mechanism by which commands, functions, and scripts communicate success or failure to their caller. The previous chapter covered functions and the return command; this chapter covers the exit status in detail: the $? variable that holds it, the if and &&/|| constructs that branch on it, the set -e option that changes its behavior, and the conventions that make exit codes meaningful across a script.
The exit status is not a value in the same sense as a string or a number that a function computes. It is a status: 0 means success, and any non-zero value means failure. The value of a non-zero status can carry information about the type of failure, but the only portable convention is that 0 is success. The return command in a function and the exit command in a script both set the status, and the caller reads it with $? immediately after the call.
Key point: $? is the exit status of the most recently executed foreground command. It is overwritten by every subsequent command, so it must be captured immediately if it is needed. set -e causes the script to exit when a command fails, but it has exceptions that make it unreliable for general error handling. The && and || operators branch on the exit status without checking $? explicitly, and the if statement uses the exit status as its condition.
Why exit statuses require careful handling
The overwrite problem.
$? holds the status of the last command, and every command overwrites it. A script that runs a command, then runs echo, then checks $? is checking the status of echo, not the original command. The status must be captured immediately after the command that produced it, either by assigning it to a variable or by using the command directly in a conditional. The if command; then and command && action forms avoid the problem because they check the status at the moment the command finishes.
The set -e problem.
set -e causes the shell to exit when a command returns a non-zero status. It is a tempting way to handle errors without checking every command, but it has exceptions: commands in an if condition, commands before && or ||, commands whose status is inverted with !, and commands in a pipeline other than the last one are not affected by set -e. A script that relies on set -e for error handling may continue past a failure that was in a condition or a pipeline, and the failure goes undetected. The BashFAQ and the Bash Hackers Wiki recommend against set -e for general error handling and in favor of explicit checks.
The status-range problem.
The exit status is an 8-bit value, so it ranges from 0 to 255. A command that returns 256 is reported as 0, and a command that returns -1 is reported as 255. The return command in a function truncates the value to 8 bits, and the exit command does the same. A function that wants to return a value larger than 255 must print it to stdout instead. The status is for success or failure, not for data.
a. Reading and capturing $?
The $? variable holds the exit status of the last command. It is read immediately after the command, before any other command runs.
#!/usr/bin/env bash
# Run a command and capture the status
ls /nonexistent
status=$?
echo "Status: $status" # 2 (ls returns 2 for a missing file)
# The status is overwritten by the next command
ls /nonexistent
echo "This is a new command"
echo "Status: $?" # 0 (the status of echo, not ls)
The ls /nonexistent command fails with status 2, and the status=$? assignment captures it. The second example runs echo between the failing command and the status check, so $? holds the status of echo, which is 0. The status must be captured or used immediately after the command that produces it.
The if statement reads the status directly without $?.
# Use the command as the condition
if grep -q "root" /etc/passwd; then
echo "Found"
fi
# Use && and || to branch on the status
grep -q "root" /etc/passwd && echo "Found" || echo "Not found"
The if grep -q ... form runs the command and branches on its status. The && operator runs the second command only if the first succeeds, and the || operator runs the third command only if the first fails. Neither form reads $? explicitly; the shell does it automatically.
The status of a pipeline is the status of its last command by default. The pipefail option changes this.
# Without pipefail: status is the last command's status
false | true
echo "$?" # 0
# With pipefail: status is the first failing command's status
set -o pipefail
false | true
echo "$?" # 1
The set -o pipefail option makes the pipeline return the status of the last command that failed, rather than the status of the final command. It is the recommended setting for scripts that check the status of pipelines.
b. The && and || operators and the if statement
The && operator runs the right-hand command only if the left-hand command succeeds. The || operator runs the right-hand command only if the left-hand command fails. The two can be combined for a conditional action.
#!/usr/bin/env bash
# Run a command only if the previous one succeeds
mkdir -p /tmp/work && cd /tmp/work || exit 1
# Run a fallback if the command fails
curl -sf https://example.com || echo "Request failed"
# Use the status in a condition
if [ -f "/etc/passwd" ]; then
echo "File exists"
else
echo "File missing"
fi
The mkdir -p /tmp/work && cd /tmp/work || exit 1 chain creates the directory and changes into it, and exits if either step fails. The curl -sf ... || echo "Request failed" command prints a message if the request fails. The if [ -f ... ] form uses the test command’s status as the condition.
The ! operator inverts the status.
# Invert the status
if ! grep -q "root" /etc/passwd; then
echo "root not found"
fi
The ! grep -q form succeeds when grep fails, which is the opposite of the command’s own status.
c. Exit codes in scripts and functions
A script returns the status of its last command unless it calls exit explicitly. The exit command sets the status and terminates the script.
#!/usr/bin/env bash
# Exit with a specific status
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <arg>" >&2
exit 1
fi
# The script exits with the status of the last command
echo "Done"
exit 0
The exit 1 terminates the script with status 1, which the caller reads with $?. The exit 0 at the end is optional, because the script exits with the status of its last command, which is echo‘s status, 0. The explicit exit 0 is clearer, and it documents the intent.
A function uses return instead of exit. The return command sets the function’s status and returns control to the caller.
#!/usr/bin/env bash
is_file() {
if [ -f "$1" ]; then
return 0
else
return 1
fi
}
if is_file /etc/passwd; then
echo "File exists"
fi
The is_file function returns 0 or 1 depending on the file’s existence. The caller uses the function in an if statement, which reads the status directly. The return command is the function’s counterpart to exit.
A function can also print a value to stdout and set a status. The caller captures the output with command substitution and checks the status with $?.
# Function that prints output and sets status
get_file_size() {
if [ ! -f "$1" ]; then
echo "File not found" >&2
return 1
fi
stat -c %s "$1"
}
size=$(get_file_size /etc/passwd)
status=$?
if [ "$status" -eq 0 ]; then
echo "Size: $size"
fi
The get_file_size function prints an error to stderr and returns 1 if the file is missing. If the file exists, it prints the size to stdout and returns the status of stat. The caller captures the output and the status separately. The error message goes to stderr, so it is not captured by the command substitution.
Complete Example Session
#!/usr/bin/env bash
# ============================================
# PART 1: CAPTURE $?
# ============================================
ls /nonexistent 2>/dev/null
status=$?
echo "ls status: $status"
# ============================================
# PART 2: THE OVERWRITE PROBLEM
# ============================================
false
echo "Between commands"
echo "Status: $?" # 0, not 1
# ============================================
# PART 3: IF AND &&/||
# ============================================
if ping -c 1 -W 1 192.168.1.1 > /dev/null 2>&1; then
echo "Host is up"
fi
ping -c 1 -W 1 192.168.1.1 > /dev/null 2>&1 && echo "Up" || echo "Down"
# ============================================
# PART 4: PIPELINE STATUS
# ============================================
false | true
echo "Without pipefail: $?"
set -o pipefail
false | true
echo "With pipefail: $?"
# ============================================
# PART 5: FUNCTION RETURN STATUS
# ============================================
is_file() {
[ -f "$1" ]
}
is_file /etc/passwd && echo "File exists"
is_file /nonexistent || echo "File missing"
# ============================================
# PART 6: FUNCTION OUTPUT AND STATUS
# ============================================
get_date() {
date +%F
}
today=$(get_date)
status=$?
echo "Today: $today (status: $status)"
# ============================================
# PART 7: EXIT IN A SCRIPT
# ============================================
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <file>" >&2
exit 1
fi
# ============================================
# PART 8: EXPLICIT ERROR CHECKS
# ============================================
if ! cp /etc/passwd /tmp/passwd.bak; then
echo "Copy failed" >&2
exit 1
fi
echo "Copy succeeded"
# ============================================
# PART 9: EXIT CODE CONVENTIONS
# ============================================
# 0 = success
# 1 = general error
# 2 = misuse of shell builtins (e.g., invalid option)
# 126 = command not executable
# 127 = command not found
# 128+N = killed by signal N
# 130 = terminated by Ctrl+C (SIGINT)
# ============================================
# PART 10: TRAP FOR CLEANUP
# ============================================
cleanup() {
echo "Cleaning up..." >&2
rm -f /tmp/workfile
}
trap cleanup EXIT
touch /tmp/workfile
echo "Script running"
The ten parts demonstrate the exit status surface: the $? capture, the overwrite problem, the if and &&/|| forms, the pipeline status, the function return, the function output, the script exit, the explicit error check, the exit-code conventions, and the trap handler. The critical distinction is between Part 1 and Part 2: the capture must happen immediately, and the intervening echo overwrites the status.
Quick Reference
Status Variables and Operators
| Element | Meaning |
|---|---|
$? | Status of the last command |
if cmd; then | Branch on the status |
cmd && next | Run next if cmd succeeds |
cmd || next | Run next if cmd fails |
! cmd | Invert the status |
exit N | Exit the script with status N |
return N | Return from a function with status N |
Common Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of shell builtins |
| 126 | Command not executable |
| 127 | Command not found |
| 128+N | Killed by signal N |
| 130 | Terminated by Ctrl+C |
Pipeline Options
| Option | Effect |
|---|---|
set -o pipefail | Pipeline returns the first failing status |
set -e | Exit on first failure (with exceptions) |
set -u | Exit on unset variable |
set -x | Trace commands |
Best Practices
โ Do This:
# Capture the status immediately
cmd; status=$? # โ
# Use the command as a condition
if cmd; then ...; fi # โ
# Use && and || for short branches
cmd && echo "ok" || echo "failed" # โ
# Use pipefail for pipelines
set -o pipefail # โ
# Use return in functions
is_file() { [ -f "$1" ]; } # โ
# Explicit error check
if ! cp src dst; then echo "failed" >&2; exit 1; fi # โ
# Trap for cleanup
trap cleanup EXIT # โ
โ Don’t Do This:
# Check $? after another command
cmd; echo "done"; if [ $? -eq 0 ]; then ...; fi # โ
# Rely on set -e for all errors
set -e # Conditions and pipelines bypass it # โ
# Use return for a string
get_name() { return "Alice"; } # โ
# Ignore the status
cmd # Failure goes undetected # โ
# Use $* instead of "$@"
for arg in $*; do ...; done # โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
$? is always 0 | Checked after another command | Capture immediately |
| Pipeline hides a failure | Default status is the last command | set -o pipefail |
set -e does not exit | Command in a condition or pipeline | Explicit checks |
| Function returns wrong value | Used return for a string | Use echo and capture |
| Status truncated | Value exceeds 255 | Use stdout for data |
Real-World Examples
1. Capture status
cmd; status=$?; echo "$status"
2. If condition
if ping -c 1 host; then echo "up"; fi
3. && and ||
mkdir -p /tmp/x && cd /tmp/x || exit 1
4. Pipeline status
set -o pipefail; grep pattern file | wc -l
5. Function return
is_root() { [ "$(id -u)" -eq 0 ]; }; is_root && echo "root"
6. Function output
name=$(get_name); echo "$name"
7. Explicit exit
[ -f "$config" ] || { echo "missing" >&2; exit 1; }
8. Trap cleanup
trap 'rm -f /tmp/workfile' EXIT
9. Signal exit code
kill -INT $$; echo "$?" # 130
10. Command not found
nonexistent_command; echo "$?" # 127
Visual
The $? Overwrite Problem
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ $? IS OVERWRITTEN BY EVERY COMMAND โ
โ โ
โ โ Incorrect: โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ls /nonexistent # status 2 โ โ
โ โ echo "checking" # status 0 โ โ
โ โ if [ $? -eq 0 ]; # checks echo, not ls โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ
Correct: โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ if ls /nonexistent; then ...; fi โ โ
โ โ # or โ โ
โ โ ls /nonexistent; status=$? โ โ
โ โ echo "checking" โ โ
โ โ if [ "$status" -eq 0 ]; then ...; fi โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Pipeline Status
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PIPE FAILS ARE HIDDEN WITHOUT pipefail โ
โ โ
โ Without pipefail: โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ false | true โ โ
โ โ echo $? โ 0 (the status of true, the last command) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ With pipefail: โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ set -o pipefail โ โ
โ โ false | true โ โ
โ โ echo $? โ 1 (the status of false, the first failure)โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Return vs Exit
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ RETURN IS FOR FUNCTIONS; EXIT IS FOR SCRIPTS โ
โ โ
โ Function: โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ is_file() { [ -f "$1" ]; return $?; } โ โ
โ โ is_file /etc/passwd; echo $? โ 0 โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Script: โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ [ -f "$1" ] || { echo "missing" >&2; exit 1; } โ โ
โ โ # the script terminates with status 1 โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Status variable | $? |
| Success | 0 |
| Failure | Non-zero (1โ255) |
| Function return | return N |
| Script exit | exit N |
| Condition | if cmd; then |
| Short branch | cmd && next / cmd || next |
| Pipeline status | Last command by default |
pipefail | First failing command in the pipeline |
| Common codes | 1 general, 2 misuse, 126 not executable, 127 not found, 130 SIGINT |
Key takeaways:
$?holds the status of the last command and is overwritten by every command. It must be captured immediately or used directly in a conditional. Theif cmdandcmd && nextforms avoid the overwrite problem because they check the status at the moment the command finishes.- 0 is success; non-zero is failure. The status range is 0 to 255. The value can carry information about the failure type, but the only portable convention is that 0 means success.
set -o pipefailmakes pipelines report the first failure. Without it, the pipeline’s status is the status of its last command, and a failure in an earlier command goes undetected.set -eis not reliable for error handling. Commands in conditions, commands before&&or||, and commands in pipelines other than the last one are not affected byset -e. Explicit checks are more reliable.returnis for functions;exitis for scripts. Both set the status, butreturnreturns control to the caller andexitterminates the script. The function’s status is read by the caller with$?or a conditional.- A function that returns data uses stdout, not
return. Thereturncommand only sets the status. A string or a computed value is printed to stdout and captured with command substitution, and the status is checked separately.
Remember: The exit status is the shell’s success-or-failure signal. $? holds it, but it is overwritten immediately, so it must be captured or used at the moment the command finishes. The if statement and the &&/|| operators check it directly. The pipefail option makes pipelines report failures. The return command sets a function’s status, and the exit command terminates a script. The status is not data; it is the answer to the question “did the command succeed?” A function that needs to return data prints it to stdout.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!