The most-deployed untested code in your company is Bash
Count the shell scripts in your container entrypoints, CI jobs, cron tables, user data and “temporary” fixes. Then count how many have a test. Bash quietly runs a large share of production, and it gets the least review of any language there.
In 2026 there’s more of it than ever, because coding assistants write shell fluently and fast. A plausible-looking 80-line script now takes seconds to produce and gets the same light review as one typed by hand. Speed of writing went up. Scrutiny didn’t.
Most teams’ answer is a header line:
set -euo pipefail
It helps. It also gives a false sense of safety, because there are well-known holes in it.
Where set -e doesn’t stop you
1. Command substitution inside local or export.
local version=$(get_version) # get_version fails... and the script continues
local succeeds, so its exit status hides the failure. Declare and assign on separate lines. ShellCheck flags this as SC2155.
2. Anything in a condition. set -e is disabled inside if, while, && and ||, including in every function those call. A function that “always exits on error” doesn’t, when it’s called as if deploy; then.
3. Subshells in Bash before 4.4. Command substitution doesn’t inherit -e unless you also set shopt -s inherit_errexit.
4. Unquoted variables. rm -rf $DIR/ with an empty $DIR is the famous one. set -u only helps if the variable is unset, not if it’s set to an empty string. Quote every expansion. Use "${DIR:?}" when emptiness should be fatal.
5. Partial work. Exiting on error is half the job. A script that dies halfway through a deploy leaves things half-deployed. Use trap cleanup EXIT and mktemp for temporary state, and write steps that can safely run twice.
Review it like code
- ShellCheck in CI, as a failing check. It catches most of the above, plus quoting bugs nobody sees until a filename has a space in it.
- A
--dry-runflag on anything destructive. Print the commands, don’t run them. - Logs with timestamps to stderr, so the cron email or CI log says what happened and when.
- Tests for the risky parts. Bats (Bash Automated Testing System) runs scripts with fake inputs. Even three tests around argument parsing pay off.
Know when to stop writing Bash
Bash is excellent glue: run these commands, in this order, with these environment variables. It’s a poor place for anything else. A useful line to draw: when a script needs any of the following, rewrite it in Python (or Go, if it needs to ship as one binary):
- Data structures beyond a flat list
- Parsing JSON beyond a single
jqcall - Retries with backoff
- More than about 100 lines
- An API call whose error you need to handle differently from the others
Rewriting isn’t a failure of the script. It means the script became a program, and programs deserve a language with real error handling.
The test of a good shell script is what happens when it fails at 3 a.m.: does it stop cleanly, leave nothing half-done, and say what went wrong? Most scripts never get asked that until it’s 3 a.m.