Shell Scripts Don't Fail Gracefully by Default — Here's How to Make Them
"Quick Mastery of Shell" — Error Handling and Logging in Shell Programming
In the scripts we discussed in previous articles, there was an implicit assumption: all commands will succeed, all data will be clean, and all dependencies will be present. This assumption works fine on your own laptop, but when placed in a production environment — hanging in crontab, written into a CI/CD pipeline, or running on a user's machine — a script must know what to do when things go wrong.
That is the problem this article aims to solve: turning a script from something that can run into something that is reliable. We will talk about three things — exit codes, error handling, and logging. Together, these three determine whether a script's behavior when problems occur is a silent failure or a clear, controllable process.
This article's style is a bit different from the previous ones. Before, we were continuously adding capabilities; this time, we are constraining capabilities. You will see many rules about what not to do and how you should write things instead. Rules sound boring, but the root cause of 90% of shell script problems in production incidents is that these rules were not followed.
1. Exit Codes
Before discussing error handling, we must first clarify the fundamental concept of exit codes. It is the only language shell processes use to communicate success and failure to each other, and all error handling is built upon it.
1.1 What is an Exit Code
Every command, after execution, returns a number — an exit code. 0 indicates success, and any other value indicates failure. It sounds simple, but that "any other value" is the key point: the shell does not mandate the meaning of specific numbers, only stipulating that 0 = success, and everything else means something went wrong.
#!/bin/bash
ls /tmp/does_not_exist
echo "Exit code: $?"
Output:
ls: cannot access '/tmp/does_not_exist': No such file or directory
Exit code: 2
$? is the exit code of the last command. This is the most common way to query what happened with the previous command. Note that $? is a "transient state" — once you execute another command, it gets overwritten. Therefore, querying $? must be done immediately after the target command:
# Correct way
ls /tmp/not_here
result=$?
echo "$result" # Correct
# Wrong way
result=$?
ls /tmp/not_here
echo "$result" # Always 0 (the exit code of echo itself)
In the second approach, $? actually captures the exit code of the command before ls, and echo's exit code is always 0. This is one of the most common mistakes shell beginners make.
1.2 Common Exit Code Conventions
Although the shell does not enforce them, the industry has a set of conventional semantics:
| Exit Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error (many commands use it to mean "something went wrong") |
| 2 | Command argument error (bash builtins typically use this) |
| 126 | Command found but cannot execute (no execute permission) |
| 127 | Command not found |
| 128+N | Process terminated by signal N (130 = Ctrl+C, SIGINT) |
| 130 | Exited via Ctrl+C (128 + 2) |
| 137 | Killed by SIGKILL (128 + 9) |
| 143 | Terminated by SIGTERM (128 + 15), common with docker stop |
Just remember a few common ones: 1 = general error, 2 = argument error, 126 = not executable, 127 = command not found, 130 = Ctrl+C. For the rest, rely on the semantics that $? carries.
1.3 The exit Command
When writing a script, if you want the script itself to return a specific exit code, use exit:
#!/bin/bash
if [ ! -f "$1" ]
then
echo "Error: File $1 does not exist" >&2
exit 1
fi
# Normal processing
process "$1"
exit 0
A few key points:
exit 0can usually be omitted — the exit code of the last command executed by the script automatically becomes the script's exit code. However, if you want to explicitly express success, writingexit 0is clearer.exitwithout arguments is equivalent toexit $?, returning the exit code of the last command.- The exit code range is
0-255; values exceeding 255 will be modulo'd (exit 300actually returns 44). It is recommended to keep custom error codes within the 0-127 range.
1.4 Sending Error Messages to the Right Place
A detail often overlooked: a script's error messages should be output to stderr, not stdout. The difference between the two is:
stdout(standard output): The normal results of a program, which can be redirected to a file or piped to the next command.stderr(standard error): The diagnostic information of a program, displayed directly on the terminal by default.
echo "Normal output" # stdout
echo "Error message" >&2 # stderr
>&2 is a file descriptor redirection, sending the output to file descriptor 2 (which is stderr). The benefit of doing this is: when a user redirects the script's output to a file, error messages can still be displayed on the terminal:
$ ./script.sh > output.log
Error: Missing arguments! # Still displayed on the terminal
$ cat output.log
Normal output # Normal output is in the file
The >&2 syntax might look a bit odd, but any diagnostic information, error messages, or warning messages should use >&2. This is the third iron rule of shell programming — the first two iron rules being "add double quotes" and "${arr[@]}" with quotes.
2. The set Command
Anyone who writes shell scripts has likely been burned by silent failures — the script finishes running without errors, but the result is wrong. Several options of the set command are used to add guardrails to scripts, and it is strongly recommended to enable them at the beginning of every script.
2.1 set -e: Exit on Error
#!/bin/bash
set -e
cd /nonexistent_dir
echo "This line will not execute"
set -e tells the shell: if any command returns a non-zero exit code, exit the script immediately (unless in contexts like if/while/&&/||, which are inherently meant to check exit codes).
This option is somewhat controversial. Some say adding set -e makes writing scripts troublesome, requiring manual error handling everywhere, but for production scripts, set -e is mandatory — it minimizes the cost of forgetting to check for errors.
2.2 set -u: Error on Undefined Variables
#!/bin/bash
set -u
echo "$undefined_var"
The default behavior is to output an empty line; with -u enabled, accessing an undefined variable will directly cause an error and exit. This one is even more worth enabling than -e, because it can expose spelling mistakes.
#!/bin/bash
set -u
user_nmae="Zhang San" # Note: it's nmae, not name
echo "$user_name" # Without -u: outputs empty; With -u: throws an error
Without -u, this kind of typo would cause the script to silently continue running with an empty value, hiding the bug deeply.
2.3 set -o pipefail: Don't Mask Pipeline Failures
This is the easiest pitfall among the three options. Look at this example:
#!/bin/bash
set -e
ls /nonexistent | sort
echo "Pipeline execution finished"
Without pipefail, ls fails, but the pipeline's exit code is taken from sort's exit code (0), so set -e doesn't detect it, and the script continues executing — but ls's error message is completely swallowed.
With pipefail enabled:
#!/bin/bash
set -e
set -o pipefail
ls /nonexistent | sort # Immediately errors out and exits
pipefail makes the pipeline's exit code the last non-zero one among all commands. This way, any failure within any step of the pipeline will be caught.
2.4 Enabling All Three Together: The Standard
The standard opening for a production script:
#!/bin/bash
set -euo pipefail
This single line condenses three safety nets: exit on error, error on undefined variables, and don't mask pipeline failures. Almost all reliable shell scripts will have this line enabled.
But there are a few edge cases to be aware of:
First, set -e does not take effect after certain commands. For example, commands following command || true will not trigger an exit even if they fail, because the || has already "digested" the failure.
set -e
false || true
echo "Will execute" # Normal
false || echo "Failed but digested"
echo "Will execute" # Normal
Second, function return values do not trigger set -e. Look at this counter-intuitive example:
#!/bin/bash
set -e
func() {
return 1
}
func # Function returns 1, but the script does not exit
echo "This line will execute"
func's exit code is 1, but set -e does not treat the function's last command as a whole for judgment. To make a function trigger set -e, either explicitly check it when calling:
func || { echo "func failed" >&2; exit 1; }
Or have the function explicitly declare it when returning:
func() {
[ "$1" -gt 0 ] || return 1
# ...
}
Third, it also doesn't take effect inside if conditions. This is by design — if is inherently for judging success or failure; if set -e applied here too, if would be unusable.
2.5 set -x and set -v: For Debugging
#!/bin/bash
set -x
name="Zhang San"
echo "$name"
Output:
+ name='Zhang San'
+ echo 'Zhang San'
Zhang San
set -x prints the expanded form of each command before executing it, making it a nuclear weapon for debugging shell scripts. Add it during daily development to troubleshoot problems, and remove it when releasing.
set -v is more primitive than -x — it prints the original lines of the script (without expanding variables). Generally, -x is sufficient.
3. Error Handling Patterns
set -euo pipefail solves the basic protection problem, but there are always scenarios in script logic where "simply failing" isn't acceptable — like "should we continue if a file is not found" or "should we rollback if a step fails". These require explicit error handling patterns.
3.1 || and && Short-circuiting
The shell provides || (or) and && (and) to perform logic "based on the result of the previous command":
mkdir -p /tmp/work || { echo "Failed to create directory" >&2; exit 1; }
grep "pattern" file.txt && echo "Found" || echo "Not found"
cmd1 && cmd2 means "execute cmd2 only if cmd1 succeeds", and cmd1 || cmd2 means "execute cmd2 only if cmd1 fails". This is the most concise way in shell to write "success/failure branches".
When following || with multiple commands, remember to group them with { ... } (note the spaces after { and before }, and the ; at the end):
mkdir -p /tmp/work || { echo "Creation failed" >&2; exit 1; }
3.2 Defensive vs. Fail-Fast
Two styles are endlessly debated in the shell community:
Defensive (don't crash on problems, keep going):
#!/bin/bash
process() {
for file in *.txt
do
if [ -f "$file" ]
then
if ! gzip "$file"
then
echo "Failed to compress $file, skipping" >&2
continue
fi
fi
done
}
Fail-Fast (exit immediately on problems, don't leave hidden dangers):
#!/bin/bash
set -e
for file in *.txt
do
[ -f "$file" ] || continue
gzip "$file" # Failure will immediately exit the entire script
done
Which is better? It depends on the scenario. For batch processing tasks ("do X to 100 files, do as many as possible"), use defensive style; for transactional tasks ("deploy code, either succeed or rollback"), use fail-fast.
Practical experience: Fail fast if you can. Many silent bugs are caused by defensive handling swallowing errors.
3.3 Error Handling Function
If a script has multiple places that need to print an error and exit, extract it into a function:
#!/bin/bash
set -euo pipefail
# Error handling
die() {
echo "[ERROR] $*" >&2
exit 1
}
# Usage
[ "$#" -ge 1 ] || die "Usage: $0 <argument>"
[ -f "$1" ] || die "File does not exist: $1"
die is the conventionally agreed-upon function name for error-exiting in shell scripts. Used together with set -e, each || die "..." is an explicit failure check.
3.4 Cleanup and Rollback
Some operations have side effects (creating temporary files, modifying configurations, starting processes) and need cleanup upon failure:
#!/bin/bash
set -e
work_dir=$(mktemp -d)
trap "rm -rf '$work_dir'" EXIT
cd "$work_dir"
# Processing logic
download_file > data.txt
process < data.txt > result.txt
cp result.txt /final/path/
trap ... EXIT means "execute this command when the script exits (regardless of success or failure)". This is the standard pattern for cleaning up temporary resources, detailed in the next section.
More complex scenarios require transactional rollback — recording the side effects of each step and reversing them upon failure:
#!/bin/bash
set -e
# Record operations that need rollback
rollback=()
register_rollback() {
rollback+=("$*")
}
rollback_all() {
for cmd in "${rollback[@]}"
do
eval "$cmd" || echo "Rollback failed: $cmd" >&2
done
}
trap rollback_all EXIT
# Business logic
create_user "alice"
register_rollback "delete_user 'alice'"
grant_permission "alice" "admin"
register_rollback "revoke_permission 'alice' 'admin'"
echo "User created successfully"
This "register_rollback + trap" pattern is common in database migration and configuration change scripts.
4. Trap Exits
trap is one of the most underrated commands in shell. It allows you to execute specified code when the script receives a signal or exits — this is the foundation for graceful exit and resource cleanup.
4.1 Basic Syntax
trap 'commands' SIGNAL...
commands: The commands to execute when the signal is received (wrap in single quotes for deferred expansion)SIGNAL: The name of the signal to catch (SIGINT,SIGTERM,EXIT, etc.)
4.2 Three Most Common Scenarios
Scenario 1: Catching Ctrl+C
#!/bin/bash
trap 'echo "Received Ctrl+C, cleaning up..."; cleanup; exit 130' INT
cleanup() {
rm -f /tmp/workfile
}
# Simulate work
while true
do
echo "Working..."
sleep 1
done
Pressing Ctrl+C triggers SIGINT, and trap receives it, cleans up resources, and exits. exit 130 is the standard exit code for "interrupted by Ctrl+C" (128 + 2).
Scenario 2: Cleaning Up Temporary Files
#!/bin/bash
set -e
tmpfile=$(mktemp)
trap "rm -f '$tmpfile'" EXIT
# Do things with tmpfile
echo "Important data" > "$tmpfile"
# Simulate an error
[ 1 -eq 1 ] || true # Placeholder
# Regardless of success or failure, the EXIT trap will clean up
trap ... EXIT is the standard pattern for "execute cleanup no matter how the script exits". mktemp paired with trap "rm -f" EXIT is the most classic cleanup pattern in shell scripts.
Scenario 3: Ignoring Certain Signals
#!/bin/bash
trap '' HUP # Ignore SIGHUP
trap '' TERM # Ignore SIGTERM
trap '' INT # Ignore Ctrl+C
trap '' SIGNAL means "ignore this signal" — act as if the signal was never received. This usage occasionally appears in daemon processes, but in the vast majority of cases, do not ignore SIGTERM — this is the standard means for your ops colleagues to kill your process; ignoring it will prevent the service from shutting down normally.
4.3 The Magic of the EXIT Trap
EXIT is not a real signal; it is a "pseudo-signal" provided by bash — triggered when the script exits (whether it ends normally, exits via set -e, or is terminated by a signal).
#!/bin/bash
set -e
start_time=$(date +%s)
trap 'echo "Script took $(( $(date +%s) - start_time )) seconds"' EXIT
# Business logic
sleep 2
echo "Done"
Output:
Done
Script took 2 seconds
The EXIT trap, combined with set -e, performs cleanup regardless of success or failure — temporary file cleanup, time elapsed statistics, status reporting can all use it.
4.4 Commands Inside Traps
The command for trap is a string that gets re-parsed and executed by the shell when the signal is received. This means the choice between single and double quotes is critical:
#!/bin/bash
file="/tmp/workfile"
# Single quotes: deferred expansion, calculated when trap triggers
trap 'rm -f "$file"' EXIT # Correct
# Double quotes: immediate expansion, hardcodes an empty string in the trap
trap "rm -f '$file'" EXIT # Also works, but variable changes inside the trap won't be reflected
The single-quoted version's $file is expanded when the trap triggers, getting the latest value; the double-quoted version is expanded when the script loads. If the commands inside a trap need to use variables, use single quotes.
4.5 Practical Example: An "Interruptible" Batch Process
#!/bin/bash
set -euo pipefail
total=0
done=0
failed=0
cleanup() {
echo ""
echo "===== Interrupt/Exit Summary =====" >&2
echo "Processed: $done / $total, Failed: $failed" >&2
exit 130
}
# Call cleanup on Ctrl+C
trap cleanup INT
# Print summary on normal exit
trap 'echo "===== Complete ====="; echo "Processed: $done / $total, Failed: $failed"' EXIT
files=(*.log)
total=${#files[@]}
for file in "${files[@]}"
do
if grep -q "ERROR" "$file"
then
if ! gzip "$file"
then
((failed++)) || true
fi
fi
((done++))
echo "Progress: $done/$total"
done
Note ((failed++)) || true — under set -e, if failed was previously 0 and increments to 1, ((...)) returns 0 (because the result is non-zero, bash treats it as "success"), but conversely, if failed was previously 1 and increments to 2, ((...)) returns a non-zero value, triggering set -e to exit. This is a counter-intuitive bash feature; adding || true is a safety measure.
5. Logging
Error handling solves the problem of what to do when things go wrong; logging solves the problem of how to investigate when things go wrong. The two are only complete when paired together.
5.1 Why Logging
Newcomers often think: "I wrote the script myself, I run it myself, why add logging?" But the truth about production scripts is:
- You might not be present (crontab runs it in the early morning)
- There is no scene left after failure (a one-off script exits after running)
- Troubleshooting relies on the scene (the input, state, and call chain at the moment of error)
Logs are the black box of a script. Without logs after a problem occurs, troubleshooting is just guesswork.
5.2 Basic Requirements for Logging
A qualified log entry should at least answer these questions:
- When: Timestamp (accurate to seconds or milliseconds)
- What level: INFO / WARN / ERROR / DEBUG
- Where it occurred: Script name, function name, line number (optional)
- What happened: A clear, one-sentence description
The simplest version:
[2024-01-15 10:23:45] [INFO] Started processing file input.log
[2024-01-15 10:23:46] [ERROR] Parsing failed: Line 12 has incorrect format
[2024-01-15 10:23:46] [INFO] Processing complete: 95 succeeded, 5 failed
5.3 Writing a Logging Function
#!/bin/bash
set -euo pipefail
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
log() {
local level=$1
shift
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
# Filter low-level logs
case $level in
DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
INFO) [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
WARN) [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
esac
local msg="[$timestamp] [$level] $*"
if [[ -n "$LOG_FILE" ]]
then
echo "$msg" | tee -a "$LOG_FILE" >&2
else
echo "$msg" >&2
fi
}
# Usage
log INFO "Starting processing"
log WARN "Configuration file does not exist, using defaults"
log ERROR "Database connection failed"
log DEBUG "Internal variable: $internal_var" # Not displayed by default
A few design points:
- Logs output to stderr (
>&2) — normal results and data go to stdout, diagnostic information goes to stderr, not interfering with each other. - Supports log level filtering —
LOG_LEVEL=DEBUG ./script.shcan show all debug information, defaulting to INFO level. - Supports output to a file —
LOG_FILE=/var/log/myscript.log ./script.shcan save logs. tee -aoutputs to both file and stderr — visible in the terminal and saved to a file simultaneously.
5.4 Debug Logging
Debug logs are key to troubleshooting problems, but you don't want them too noisy in a production environment. A common practice is to use BASH_SOURCE and LINENO to automatically add location information:
#!/bin/bash
set -euo pipefail
debug() {
[[ "${DEBUG:-0}" == "1" ]] || return 0
local timestamp
timestamp=$(date '+%H:%M:%S.%3N')
echo "[$timestamp] [DEBUG] ${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]} $*" >&2
}
# Usage
debug "Entering process function, argument: $1"
process() {
debug "Processing $1"
# ...
}
Output (with DEBUG=1):
[10:23:45.123] [DEBUG] script.sh:14 Entering process function, argument: input.log
[10:23:45.124] [DEBUG] script.sh:19 Processing input.log
BASH_SOURCE[1] and BASH_LINENO[0] are the "call stack" maintained internally by bash; the subscript 1 indicates "the file that called the current function" and "that line". This trick is very useful for debugging in large scripts.
5.5 Using the logger Command to Write System Logs
Linux comes with the logger command, which can write messages to syslog:
#!/bin/bash
log() {
local level=$1
shift
logger -t "myscript" -p "user.$level" "$*"
}
# Usage
log INFO "Starting processing"
log ERROR "Database connection failed"
The output will go to /var/log/syslog (Debian/Ubuntu) or /var/log/messages (CentOS/RHEL), and can be viewed using journalctl -t myscript. Suitable for long-running service scripts, not suitable for one-off tasks.
5.6 Log Rotation
Writing too many logs can fill up the disk. Log files for production scripts need rotation — periodic cutting, compression, and cleanup.
If using logger to go through syslog, rotation is handled by the system's logrotate. If writing files yourself, you can use a logrotate configuration file:
# /etc/logrotate.d/myscript
/var/log/myscript.log {
daily
rotate 7
compress
missingok
notifempty
postrotate
# Notify script to reopen log file
kill -HUP $(cat /var/run/myscript.pid 2>/dev/null) 2>/dev/null || true
endscript
}
Or handle it within the script itself (using SIGHUP to trigger reopening the log file). Everyday scripts won't need this level of complexity; just knowing it exists is enough.
6. Complete Practical Example
In this section, we'll see how to harden a data processing script to production grade, observing how error handling and logging are implemented in a real project.
Original Version:
#!/bin/bash
declare -A total_amount
declare -A order_count
while IFS=',' read -r order_id product quantity price date
do
[[ "$order_id" == "order_id" ]] && continue
qty=${quantity:-0}
prc=${price:-0}
[[ ! "$qty" =~ ^[0-9]+$ ]] && continue
[[ ! "$prc" =~ ^[0-9]+$ ]] && continue
amount=$((qty * prc))
total_amount[$product]=$(( ${total_amount[$product]:-0} + amount ))
((order_count[$product]++))
done < sales.csv
echo "product,total_amount,order_count,avg_amount"
for product in "${!total_amount[@]}"
do
total=${total_amount[$product]}
count=${order_count[$product]}
avg=$((total / count))
echo "$product,$total,$count,$avg"
done | sort -t',' -k2 -rn
Hardened Version:
#!/bin/bash
#
# analyze_sales.sh - Analyze sales data
# Usage: analyze_sales.sh <input CSV> [output CSV]
#
set -euo pipefail
# ============================================================
# Configuration
# ============================================================
SCRIPT_NAME=$(basename "$0")
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
# ============================================================
# Logging Function
# ============================================================
log() {
local level=$1
shift
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
# Level filtering
case $level in
DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
INFO) [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
WARN) [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
esac
local msg="[$timestamp] [$level] $SCRIPT_NAME: $*"
if [[ -n "$LOG_FILE" ]]
then
echo "$msg" | tee -a "$LOG_FILE" >&2
else
echo "$msg" >&2
fi
}
die() {
log ERROR "$*"
exit 1
}
# ============================================================
# Argument Validation
# ============================================================
[ "$#" -ge 1 ] || die "Usage: $SCRIPT_NAME <input CSV> [output CSV]"
INPUT=$1
OUTPUT=${2:-}
[ -f "$INPUT" ] || die "Input file does not exist: $INPUT"
[ -r "$INPUT" ] || die "Input file is not readable: $INPUT"
log INFO "Starting analysis of $INPUT"
# ============================================================
# Temporary Files
# ============================================================
work_dir=$(mktemp -d)
trap "rm -rf '$work_dir'" EXIT
# ============================================================
# Business Logic
# ============================================================
declare -A total_amount
declare -A order_count
declare -A invalid_rows
total_rows=0
skipped_rows=0
while IFS=',' read -r order_id product quantity price date
do
((total_rows++)) || true
# Skip header
[[ "$order_id" == "order_id" ]] && continue
# Field validation
if [[ -z "$product" ]]
then
invalid_rows[$total_rows]="Missing product"
((skipped_rows++)) || true
continue
fi
if [[ ! "$quantity" =~ ^[0-9]+$ ]] || [[ ! "$price" =~ ^[0-9]+$ ]]
then
invalid_rows[$total_rows]="quantity/price non-numeric: $quantity/$price"
((skipped_rows++)) || true
continue
fi
# Aggregation
amount=$((quantity * price))
total_amount[$product]=$(( ${total_amount[$product]:-0} + amount ))
((order_count[$product]++)) || true
done < "$INPUT"
# ============================================================
# Output
# ============================================================
output_file="$work_dir/result.csv"
exec 3> "$output_file" # Open file descriptor 3 pointing to output
echo "product,total_amount,order_count,avg_amount" >&3
for product in "${!total_amount[@]}"
do
total=${total_amount[$product]}
count=${order_count[$product]}
avg=$((total / count))
echo "$product,$total,$count,$avg" >&3
done | sort -t',' -k2 -rn
exec 3>&- # Close file descriptor
# ============================================================
# Wrap-up
# ============================================================
if [[ -n "$OUTPUT" ]]
then
cp "$output_file" "$OUTPUT"
log INFO "Results written to: $OUTPUT"
else
cat "$output_file"
fi
log INFO "Processing complete: Total rows $total_rows, Skipped $skipped_rows, Successful ${#total_amount[@]} products"
if [[ ${#invalid_rows[@]} -gt 0 ]]
then
log WARN "${#invalid_rows[@]} rows have issues:"
for row in "${!invalid_rows[@]}"
do
log WARN " Row $row: ${invalid_rows[$row]}"
done
fi
Comparing the two versions, the hardened one adds these capabilities:
set -euo pipefail: All three safety nets enabled- Argument validation: Missing arguments, non-existent files, unreadable files all result in immediate error and exit
- Logging function: Three levels INFO/WARN/ERROR, controllable level, optional output to file
- Temporary directory + trap EXIT: Automatic cleanup when the script exits
- Data validation: Missing fields, non-numeric values are all recorded in
invalid_rows, not silently discarded - Statistical output: Total rows, skipped count, successful product count, details of problematic rows
- Standard exit codes: Returns 0 on success, 1 on argument error
How to use it:
# Normal usage: results output to stdout
./analyze_sales.sh sales.csv
# Output to file + detailed logging
LOG_LEVEL=DEBUG LOG_FILE=/var/log/analyze.log ./analyze_sales.sh sales.csv result.csv
In contrast, what would the unhardened version do?
- Missing argument →
cat sales.csvreads the script itself as data, causing chaotic results - File does not exist → Enters
while readwith no data, outputs nothing, and in crontab you just see an empty email - Abnormal numeric fields → Directly
continue, problematic rows are silently swallowed - Residual temporary files → Disk slowly fills up
This is the gap between "can run" and "reliable".
7. Summary
Error handling and logging are the keys to turning a script from a toy into a tool. Let's review the key points:
- Exit codes are the lingua franca of the shell world;
$?is the query method,exit Nis the return method. set -euo pipefailis the standard for scripts, but know that it doesn't take effect in if/while/|| contexts.- Error handling patterns: Use
set -efor fail-fast whenever possible; only consider defensive style when you need "do as much as you can". traphandles signals and cleanup:trap '...' EXITcleans up temporary resources,trap '...' INThandles Ctrl+C.- Logging needs timestamps, levels, and clear descriptions; adjustable levels, optional output files, debug mode using
BASH_SOURCE+BASH_LINENO. - Hardening a script =
set -euo pipefail+ argument validation + trap cleanup + logging + standard exit codes.
In this article, we've completed the robustness of our scripts. Next, we'll continue down this path of engineering — talking about how to modularize shell scripts: extracting common functions into library files, how to write scripts that can be sourced, and how to handle multi-script projects. When a script grows from a few hundred lines to a few thousand, whether you can organize it well is the difference between being able to write it and being able to maintain it.
The examples in this article were tested in GNU bash 4.3+ environments.
BASH_SOURCEandBASH_LINENOare supported since bash 3.0+. Theloggercommand requires a syslog service; for containerized deployments, it is recommended to use file logging instead.date '+%3N'(milliseconds) requires GNU date.