Bash does not have a debugger, but it has something close: an execution trace, a linter with opinions, and the discipline of printing what you claim. Between bash -x, shellcheck, and honest logging, almost no script bug survives an afternoon.
bash -x: watch it run #
The trace flag prints every expanded command before execution — variables already filled in, which is exactly the information print-debugging lacks:
$ bash -x deploy.sh prod
+ set -euo pipefail
+ [[ 1 -ne 2 ]]
+ region=prod
+ rsync -a dist/ server:/srv/prod/
+ prefixes trace lines; the difference between what you wrote and what bash expanded is where the bug lives. Scope it when the whole run is too noisy:
set -x
rsync -a "$src" "$dst"
set +x
shellcheck: the reviewer #
shellcheck reads the script statically and names the bug class — unquoted expansion, useless cat, missing quoting in a heredoc, [ x ] && [ y ] versus [[ x && y ]]:
$ shellcheck deploy.sh
In deploy.sh line 12:
for f in $(ls *.log); do
^-- SC2045: Iterating over ls output is fragile...
Every message links to a wiki page explaining the fix. Most experienced bash developers run it continuously — in the editor, or as a git pre-commit hook so it reviews every change.
printf: honest logging #
Debug output belongs on stderr, timestamped, and distinguishable from real output — the same discipline as the logging function:
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
debug() { [[ ${DEBUG:-0} == 1 ]] && log "DEBUG: $*" || true; }
DEBUG=1 ./deploy.sh
The DEBUG=1 gate turns tracing on without editing the script — and stderr keeps stdout clean for pipelines and exit-code honesty.
The method #
| Symptom | First move |
|---|---|
| Wrong values, right flow | bash -x, compare written vs expanded |
| Works alone, fails in pipe | Check pipefail and subshells |
| "Works in my shell" | Quote and locale differences; shellcheck |
| Variable mysteriously empty | set -u, then trace where it was set |
| Intermittent | Trace to a file: bash -x script 2> trace.log |
Prevention starts earlier: strict mode turns silent corruption into immediate, named errors. The scripting series continues.
