跪拜 Guibai
← All articles
Shell

Shell Scripts Don't Fail Gracefully by Default — Here's How to Make Them

By 柒号华仔 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Shell scripts are the glue in most production pipelines, yet they ship with zero guardrails by default. A script that silently continues after a failed `cd` or a typo in a variable name can corrupt data or mask outages for hours. The patterns here — `set -euo pipefail`, `trap` cleanup, stderr logging — are the minimum bar for any script that runs without a human watching.

Summary

Most shell scripts start with an implicit assumption that every command succeeds. That assumption breaks the moment a script runs in crontab, CI/CD, or on someone else's machine. The fix is a combination of exit code conventions, the `set -euo pipefail` safety net, and explicit error-handling patterns like `trap` for cleanup and `die()` for controlled exits.

A hardened script also needs logging that answers when, where, and what went wrong — with timestamps, severity levels, and output to stderr so diagnostics don't mix with data. The article walks through a before-and-after refactor of a CSV processing script, showing how argument validation, temporary file cleanup, and invalid-row tracking turn a fragile one-liner into something that can run unattended.

The difference between "it ran" and "it ran correctly" is these guardrails. Silent failures — missing files, typo'd variable names, swallowed pipeline errors — are the most common source of production shell bugs, and they're all preventable with a few lines at the top of every script.

Takeaways
`set -euo pipefail` should be the first line after the shebang in every production script; it catches failed commands, undefined variables, and swallowed pipeline errors.
Exit code 0 means success; any non-zero means failure. `$?` captures the last command's exit code but is overwritten by the very next command, so check it immediately.
Error messages belong on stderr (`>&2`), not stdout, so they still reach the terminal when normal output is redirected to a file.
`set -e` does not trigger inside `if`, `while`, `||`, or `&&` contexts, and it ignores bare function return values — call functions with `|| die` to enforce it.
`trap '...' EXIT` runs cleanup code no matter how the script exits, making it the standard pattern for removing temporary files and directories.
Arithmetic expressions like `((count++))` can trigger `set -e` when the result is zero; guard them with `|| true`.
Logging functions should support level filtering (DEBUG/INFO/WARN/ERROR), output to stderr, and optional file output via `tee -a`.
`BASH_SOURCE[1]` and `BASH_LINENO[0]` provide automatic file-and-line context in debug logs without manual instrumentation.
A hardened script adds argument validation, data validation with invalid-row tracking, temporary directory cleanup, and a summary of what was processed and skipped.
Conclusions

Shell's default behavior is optimized for interactive use, not automation — it silently ignores undefined variables and pipeline failures, which is the opposite of what production scripts need.

The `set -e` controversy exists because its edge cases (function returns, arithmetic expressions, conditional contexts) are genuinely surprising, but the alternative — manual error checks on every line — is worse.

Defensive error handling that swallows failures is more dangerous than fail-fast scripts that crash loudly; a script that produces wrong output without errors is harder to debug than one that refuses to run.

Logging to stderr rather than stdout is a simple convention that separates data from diagnostics, yet most shell tutorials never mention it, leading to scripts where errors vanish into redirected output files.

Concepts & terms
Exit code
An integer (0–255) returned by every command on completion. 0 means success; any non-zero value means failure. The shell does not standardize specific non-zero meanings, but conventions exist (1 = general error, 2 = misuse, 127 = command not found).
set -euo pipefail
A combined shell option: `-e` exits on any command failure, `-u` treats unset variables as errors, `-o pipefail` makes a pipeline's exit code the last non-zero exit code in the chain. Together they prevent silent failures.
trap
A shell builtin that executes specified commands when the script receives a signal (SIGINT, SIGTERM) or exits. `trap '...' EXIT` is the standard pattern for guaranteed cleanup of temporary resources.
stderr (standard error)
File descriptor 2, used for diagnostic output. Redirecting with `>&2` ensures error messages appear on the terminal even when stdout is piped or redirected to a file.
BASH_SOURCE and BASH_LINENO
Bash internal arrays that track the call stack. `BASH_SOURCE[1]` gives the filename of the caller, and `BASH_LINENO[0]` gives the line number of the call, enabling automatic debug-log location tagging.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗