Abhinav C

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-run flag 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 jq call
  • 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.

Filed under: three lines that grew up.

More writing