Closing 05-02 we identified the hole daily-report.sh still has: if something fails inside, the script never finds out. Bash, by design, runs the next line no matter what happened on the previous one. If the grep over the CSV finds nothing, if a variable is misspelled and expands to the empty string, if the disk fills up mid-write, the script carries on and produces a silently false report —the worst thing an operations tool can do, because nobody suspects a wrong number that arrives punctually every morning—. And on top of that it leaves behind the mktemp temporary we promised in 05-01 to learn to clean up. This lesson gives Bash the discipline it does not ship with, and gives you the tools to see what is going on when something does not add up.
Contents
- The silent failure
set -e: what it catches and what it does notset -u: undefined variablesset -o pipefailandPIPESTATUSset -euo pipefailand its honest critiquetrap: signals and pseudo-signals- Guaranteed cleanup with
EXIT die()andtrap ... ERRwith context- Exit-code convention
- Retries with backoff
- Debugging:
-n,-x,PS4,-v,DEBUGand bisection daily-report.shhardened
- The silent failure
#!/usr/bin/env bash
cd /srv/veloz/data/nonexistent # fails: "No such file or directory"
rm -rf ./* # runs ANYWAY, in the current directoryThat example, in one variant or another, has destroyed real servers. Bash reports the error on stderr and carries on. Against that there are three switches.
set -e: what it catches and what it does not
set -e: what it catches and what it does notset -e (or set -o errexit) makes the script terminate as soon as a command returns a non-zero code: with it at the top, the previous example dies at the cd and never reaches the rm. What matters is knowing what it does not catch, because the exceptions are many and deliberate: a command whose failure is part of a decision must not kill the script.
| Situation | Does set -e abort? |
Why |
|---|---|---|
grep pattern f with no matches |
Yes | It returns 1 and is not protected |
if grep -q pattern f; then |
No | It is an if condition |
while ! ping -c1 host; do |
No | It is a loop condition |
grep -q x f && echo found |
No | Unless the last one in the chain fails |
| `command | true` | |
! command |
No | It is negated |
f() { false; }; if f; then |
No | The whole function loses -e inside an if |
false | wc -l |
No | Only the last one in the pipeline counts (section 4) |
(( counter++ )) with counter at 0 |
Yes | Classic surprise, already seen in 04-06 |
local x=$(false) |
No | The code is local's, not the substitution's |
The last two rows are the ones that cause the most headaches. The local one has a fix: declare and assign on separate lines (local report_date and then report_date=$(date -d "$1" +%F)), and that way the failure is detected. And the second-to-last one —a function loses set -e inside it when called from an if or from an &&— is the main reason some people distrust set -e. It is not a Bash bug: it is POSIX. But it means you cannot delegate all your error handling to -e.
set -u: undefined variables
set -u: undefined variablesUnder set -u, an echo "Report for $CITYY" with the variable misspelled aborts with bash: CITYY: unbound variable. Without -u, that typo expands to the empty string and produces rm -rf "$BASE_DIR/" with BASE_DIR empty, that is, rm -rf /. It coexists perfectly with the default values from 03-06, which are the explicit way of saying "this one may not exist":
level="${VELOZ_LOG_LEVEL:-info}" # default if it is not defined
target="${1:?target is missing}" # your own error with a clear message
[[ -n "${DEBUG:-}" ]] && set -x # the :- is mandatory under set -uCareful with arrays: under -u and in Bash older than 4.4, "${arr[@]}" on an empty array aborts. The safe idiom is "${arr[@]:-}" or checking beforehand with (( ${#arr[@]} > 0 )).
set -o pipefail and PIPESTATUS
set -o pipefail and PIPESTATUSIn 02-04 this was deferred: the exit code of a pipeline is that of its last command.
zcat /var/log/veloz/nonexistent.gz | wc -l
echo $? # 0 ← success! because wc -l worked perfectly (it printed 0)
set -o pipefail
zcat /var/log/veloz/nonexistent.gz | wc -l
echo $? # 1 ← now it is rightA failure like that in daily-report.sh produces "0 errors today" when the truth is "I could not read the file". pipefail fixes it: the pipeline returns the code of the last command that failed, or 0 if none failed. When you need to know which link failed, the PIPESTATUS array holds every code, in order:
zcat access.log.2.gz | grep ' 500 ' | wc -l
echo "${PIPESTATUS[@]}" # 0 1 0 ← the grep found nothing (code 1)PIPESTATUS is overwritten by every command, including an echo. If you are going to use it, copy it first: local -a codes=( "${PIPESTATUS[@]}" ).
set -euo pipefail and its honest critique
set -euo pipefail and its honest critiqueThe header you will see in almost every serious script is set -euo pipefail right below the shebang, sometimes accompanied by a restrictive IFS=$'\n\t' (03-06). It is a good default and you should use it. But it is worth stating clearly why it is not magic:
set -ehas the hole of functions called fromif, so critical functions must check their own errors.- It aborts with a message from the command that failed, without saying which line of your script it happened on. That is fixed by the
trap ... ERRin section 8. - It turns any non-zero code into a fatal one, and there are commands where that is normal:
grepwith no matches,diffwith differences,pgrepwith no processes. You have to mark them explicitly.
For those cases, three idioms:
grep -c ERROR "$LOG" || true # "it may return 1, that is acceptable"
if ! errors=$(grep -c ERROR "$LOG"); then errors=0; fi # better: distinguishes the case
set +e; command_that_often_fails; code=$?; set -e # disable it locallyThe rule: || true for the trivial case, if ! when you want to react, and set +e/set -e confined to the fewest possible lines. A set +e at the top of a long script is worse than never having written -e, because it gives a false sense of safety.
trap: signals and pseudo-signals
trap: signals and pseudo-signalstrap 'commands' SIGNAL [SIGNAL...] registers code that runs when a signal arrives. Its variants: trap - SIGNAL restores the default behavior, trap '' SIGNAL ignores it and trap -p lists the active traps.
Besides the real signals from 05-02, Bash defines four pseudo-signals:
| Pseudo-signal | Fires | Typical use |
|---|---|---|
EXIT |
When the script exits, by whatever route | Guaranteed cleanup |
ERR |
When a command returns a code ≠ 0 (same exceptions as -e) |
Reporting where it failed |
DEBUG |
Before every command | Fine-grained debugging |
RETURN |
On returning from a function or from a source |
Profiling, traces |
Use single quotes in the trap's code. trap 'rm -f "$tmp"' EXIT is correct because $tmp is resolved when it fires; with double quotes it would expand when registering the trap and you would capture the wrong value.
- Guaranteed cleanup with
EXIT
EXITHere we close what was promised in 05-01 and 05-02:
readonly REPORT_TMPDIR=$(mktemp -d)
cleanup() {
local code=$? # capture it BEFORE doing anything
rm -rf "$REPORT_TMPDIR"
exit "$code" # preserve the original code
}
trap cleanup EXITEXIT fires on exit, when the last line finishes, when set -e aborts and when a trapped signal arrives. Two details people forget: the local code=$? must be the first line of the function, because any command before it overwrites the value; and the final exit "$code" stops the cleanup from turning a failure into an apparent success. So that Ctrl-C also goes through it, add the signals: trap cleanup EXIT INT TERM HUP.
die() and trap ... ERR with context
die() and trap ... ERR with contextEvery serious script has a function for aborting with a message. The convention: message to stderr, meaningful exit code.
# die — Writes an error message and terminates. Usage: die <code> <message...>
die() {
local code="${1:?}"; shift
printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2
exit "$code"
}
validate_env() {
[[ -r "$CSV_PATH" ]] || die 66 "Cannot read $CSV_PATH"
command -v bc > /dev/null || die 69 "The bc utility is missing"
}And for the failures you did not anticipate, an ERR trap that says exactly where it happened:
trap 'printf "[FATAL] %s:%d in %s(): code %d\n" "${BASH_SOURCE[0]}" \
"$LINENO" "${FUNCNAME[0]:-main}" "$?" >&2' ERRThe three variables that supply the context are the ones that turn a useless message into a diagnosis:
| Variable | Contents |
|---|---|
$LINENO |
Current line number |
${BASH_SOURCE[0]} |
File that line lives in (key with libraries, 05-06) |
${FUNCNAME[0]} |
Function in progress; the whole array is the call stack |
FUNCNAME is an array: ${FUNCNAME[1]} is whoever called the current function, and by walking it alongside BASH_LINENO you can print a complete stack trace. One caveat: for the ERR trap to be inherited inside functions and subshells you need set -E (or set -o errtrace); without it, functions do not fire it.
- Exit-code convention
In 03-01 you saw exit N. Now the convention, which matters when another script or cron reads your result:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error |
| 2 | Incorrect usage: missing arguments or unknown option |
| 64 | EX_USAGE: error on the command line |
| 65 | EX_DATAERR: the input data is incorrect (corrupt CSV) |
| 66 | EX_NOINPUT: the input file does not exist or cannot be read |
| 69 | EX_UNAVAILABLE: a service or dependency is not available |
| 73 | EX_CANTCREAT: the output file cannot be created |
| 78 | EX_CONFIG: configuration error |
| 124 / 126 | timeout expired (05-02) / the file exists but is not executable |
| 127 / 130 | Command not found (not on the PATH) / Ctrl-C (128 + 2, SIGINT) |
The ones in the 64-78 range come from BSD's sysexits.h; not everybody uses them, but they are a better convention than inventing numbers. And the 128 + N rule for signals explains at a glance the 137 (128+9, SIGKILL) and the 143 (128+15, SIGTERM). Reserve the codes above 125 and do not use values greater than 255: they are truncated modulo 256, so exit 256 is exit 0.
- Retries with backoff
Network failures are transient by nature: retrying is correct, but retrying immediately and without a limit is an attack on your own server. The pattern is exponential backoff:
# retry — Runs a command with a growing wait. Usage: retry <n> <command...>
retry() {
local attempts="${1:?}" n=1 delay=1; shift
until "$@"; do
(( n >= attempts )) && { printf 'Failed after %d attempts\n' "$n" >&2; return 1; }
sleep "$delay"; (( delay *= 2 )); (( ++n ))
done
}
retry 5 timeout 10 curl -sf http://localhost:8080/enviosThe waits are 1, 2, 4, 8 seconds. Note the combination with timeout from 05-02: without it, one hung attempt keeps you from ever reaching the next. The real-world use with curl and APIs arrives in 06-04 and 06-05.
- Debugging:
-n, -x, PS4, -v, DEBUG and bisection
-n, -x, PS4, -v, DEBUG and bisectionbash -n daily-report.sh # only checks the SYNTAX, runs nothing
bash -x daily-report.sh # trace: prints each command already expanded
bash -v daily-report.sh # prints each line as-is, before expandingbash -n should be a reflex before saving any script destined for cron: it spots the missing fi without running the rm. And set -x / set +x confine the trace to the suspect part, which is what makes it usable in a long script.
The trace goes to stderr prefixed with +. That prefix is the PS4 variable, and enriching it turns an illegible trace into a diagnosis:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): '
exec 8> ~/veloz-ops/logs/trace.$$
export BASH_XTRACEFD=8 # the trace goes to descriptor 8, not to stderr
set -x
accumulate_day "$report_date"
set +xThe resulting trace looks like this —+ daily-report.sh:91:accumulate_day(): [[ 2026-08-03 == 2026-08-03 ]]—: every line states file, line and function. And BASH_XTRACEFD diverts it to a file so it does not mix with the report's output (descriptor 8 is from the family you will see in depth in 05-05). For its part, trap 'printf "line %d: total=%s\n" "$LINENO" "${total:-}" >&2' DEBUG inspects the state before every command, useful for hunting down where a variable changes.
When none of this is enough, there is still bisection: put an exit 99 halfway through the script, see whether the problem shows up, move the exit to the middle of the relevant half and repeat. With five or six runs you locate the guilty line in a thousand-line script. And to prevent rather than cure, shellcheck statically detects most of these faults before running anything: that is the subject of 08-05.
daily-report.sh hardened
daily-report.sh hardened#!/usr/bin/env bash
# daily-report.sh — Veloz Envíos daily delivery report
set -Eeuo pipefail # -E: functions inherit the ERR trap
readonly CSV_PATH="${VELOZ_CSV:-/srv/veloz/data/shipments.csv}"
REPORT_TMPDIR=$(mktemp -d) || exit 73; readonly REPORT_TMPDIR
cleanup() { local c=$?; rm -rf "$REPORT_TMPDIR"; exit "$c"; }
trap cleanup EXIT INT TERM
trap 'printf "[FATAL] %s:%d in %s(): code %d\n" "${BASH_SOURCE[0]}" \
"$LINENO" "${FUNCNAME[0]:-main}" "$?" >&2' ERR
main() {
[[ "${1:-}" == --debug ]] && { shift; enable_debug; }
validate_env
...
}
main "$@"Four header lines and two traps: the script no longer lies. If the CSV cannot be read, it dies with 66 and a message; if something unexpected fails, it states file, line and function; and the temporary is deleted along all four possible exit routes.
Common Mistakes and Tips
- Believing
set -ecovers everything. Functions called fromiflose it. Check by hand as well. trapwith double quotes. The variables expand when the trap is registered, not when it fires.- Not capturing
$?on the first line of theEXIThandler, or forgetting the finalexit "$code". A failure becomes an apparent success andcronnever warns. trap ... ERRwithoutset -E. Functions do not fire it and the diagnosis never appears.- Using
PIPESTATUSafter another command. It is overwritten by each one; copy it immediately. exit 300. It is truncated modulo 256 and exits with 44. Stay between 0 and 125.- Tip: write the cleanup trap on the same line where you create the temporary. If you leave it for "later", the early
returnyou add a month from now will take the deletion down with it.
Exercises
Exercise 1. Write a complete header for a script archive-history.sh that uses strict mode, creates a temporary directory, guarantees its deletion even on Ctrl-C, preserves the original exit code and reports the exact line on any unforeseen failure.
Exercise 2. The function count_errors() uses zgrep -c ERROR "$LOG" | tr -d ' '. Explain why under set -e without pipefail it can return a false number, and rewrite it so it distinguishes "zero errors" from "I could not read the file", exiting with 66 in the second case.
Exercise 3. Add to daily-report.sh a --debug option that enables an enriched trace (file, line and function) dumped to ~/veloz-ops/logs/trace-YYYY-MM-DD.log, without polluting either the report's output or its errors.
Solutions
Solution 1.
#!/usr/bin/env bash
set -Eeuo pipefail
WORKDIR=$(mktemp -d) || exit 73
readonly WORKDIR
cleanup() { local c=$?; rm -rf "$WORKDIR"; exit "$c"; }
trap cleanup EXIT INT TERM HUP
trap 'printf "[FATAL] %s:%d %s(): code %d\n" "${BASH_SOURCE[0]}" "$LINENO" \
"${FUNCNAME[0]:-main}" "$?" >&2' ERR-E is essential for the ERR trap to work inside functions. INT TERM HUP alongside EXIT may look redundant —a trapped signal ends up firing EXIT— but spelling them out guarantees the deletion even if someone redefines the default behavior.
Solution 2. Without pipefail, the pipeline's code is tr's, which is always 0: if zgrep could not open the file, the function returns the empty string as though it were zero errors. On top of that, zgrep -c returns 1 when there are zero matches, which under set -e would kill the script without there being a real error.
count_errors() { # Usage: count_errors <file>
local log="${1:?}" n
[[ -r "$log" ]] || die 66 "Cannot read $log"
n=$(grep -c 'ERROR' "$log") || n=0 # the || captures the "no matches" case
printf '%s\n' "$n"
}The [[ -r ]] guard separates the real failure (unreadable file → code 66) from the legitimate case (zero errors → prints 0). It is clearer than any acrobatics with PIPESTATUS, and the lesson is general: check the preconditions yourself instead of deducing them from ambiguous codes.
Solution 3.
enable_debug() {
local trace=~/veloz-ops/logs/trace-$(date +%F).log
exec 8>> "$trace" # its own descriptor, in append mode
export BASH_XTRACEFD=8
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): '
set -x
log_info "Trace enabled at $trace"
}
[[ "${1:-}" == --debug ]] && { shift; enable_debug; }BASH_XTRACEFD=8 diverts the trace to descriptor 8, so neither stdout (the report) nor stderr (the warnings) get contaminated: you can keep piping the report to a file while you read the trace in another terminal with tail -f. The >> preserves the traces from several runs on the same day.
Conclusion
Bash does not abort on its own, so robustness has to be requested explicitly. set -e kills the script at the first failure, with deliberate exceptions —conditionals, &&/||, negations, functions called from if— that you must know in order not to get complacent; set -u turns a typo into a visible error instead of a dangerous empty string; set -o pipefail fixes the exit code of pipelines, and PIPESTATUS says which link failed. The line set -Eeuo pipefail is the right starting point, but not a guarantee: you still have to mark with || true or if ! the commands whose failure is normal. trap completes the picture: EXIT gives the guaranteed cleanup that makes the mktemp + deletion pair safe, and ERR with $LINENO, BASH_SOURCE and FUNCNAME turns a mute error into a diagnosis with file, line and function. A die() with codes from the sysexits convention and a retry-with-backoff pattern round out the arsenal. And for investigating: bash -n validates syntax, set -x with an enriched PS4 and BASH_XTRACEFD produces a readable, isolated trace, and bisection with a temporary exit locates whatever resists.
There is a debt outstanding since Module 2. The toolkit validates paths, processes and codes, but when it has to validate text —a date --date 2026-08-03, an IP, an HTTP code, the structure of an app.log line— it still falls back on fragile comparisons with globs. In 05-04 real regular expressions finally arrive: the three flavors and which tool uses each one, the syntax from scratch, and Bash's =~ operator with BASH_REMATCH, which validates and extracts in a single step.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
