Bash, the good, bad and ugly

The glue holding the internet together

Bash is the language nobody chooses and everybody uses. It is the duct tape and baling wire holding the internet together: the install scripts, the CI pipelines, the cron jobs, the “quick” one-liner that has been running in production for nine years. You do not set out to write Bash; you reach for it because it is already there, on every server you will ever touch, and before you know it you have a 400-line script with feelings. In keeping with our series on programming languages, here is Bash in three acts.

The Bourne-Again Shell arrived in 1989 as a free replacement for the original Bourne shell, and the pun in its name tells you most of what you need to know about its sense of humour. It is both an interactive command line and a scripting language, which is a large part of its enduring appeal: the commands you type to explore a system are the same commands you paste into a script to automate it. That dual nature is also the root of many of its quirks, because a syntax optimised for typing one line at a time is not the same syntax you would design for writing maintainable programs.

Bash’s first virtue is that it is everywhere. Practically every Linux box, every Mac, every CI runner, and every Docker base image ships with a shell. A Bash script has no runtime to install, no dependencies to resolve, no version manager to placate. You write it, you chmod +x, you run it. For automation glue, that ubiquity is worth more than almost any language feature.

Its second virtue is the pipeline. The Unix philosophy of small tools that do one thing and pass text between them is genuinely elegant, and Bash is the language that lets you compose them. A surprising amount of real work collapses into a single readable line:

grep -h ERROR /var/log/app/*.log | awk '{print $5}' | sort | uniq -c | sort -rn | head

That counts the most frequent error sources across a pile of logs, and you can build it interactively, watching the output change as you add each stage. The feedback loop is instant. There is no compile step, no project scaffolding, nothing between you and the result. For exploring a system or wrangling files, that immediacy is hard to beat.

Bash also shines at orchestration. Calling other programs is its native tongue, so gluing together git, curl, docker, and jq into a deployment routine feels natural in a way it never quite does from a “real” programming language, where shelling out is always slightly awkward.

The trouble starts the moment your data contains a space. Word-splitting is Bash’s original sin: unquoted variables are split on whitespace, so a filename like My Holiday Photos becomes three separate arguments and your script quietly does the wrong thing.

file="My Holiday.txt"
rm $file       # tries to remove "My" and "Holiday.txt"
rm "$file"     # correct: always quote your variables

The fix is simple, “quote everything”, but the failure is silent, and that combination is what makes it a footgun rather than an error.

Data structures are the next disappointment. Bash has arrays, but the syntax to use them safely is baroque enough that people avoid it. Iterating correctly means writing "${array[@]}" with the quotes and the @ and the braces all in the right places, and getting any of it wrong reintroduces the word-splitting bug you were trying to escape. Associative arrays exist but feel bolted on.

Error handling is weak by default. A command can fail and the script sails right on, because the shell’s instinct is to keep going. There is no exception mechanism, only exit codes you have to check by hand, and the $? variable that holds the last one is easy to clobber.

Portability is a recurring tax. The #!/bin/bash you wrote on your laptop may meet a system where sh is dash, or a Mac shipping an ancient Bash, and features you relied on simply are not there. The gap between “POSIX sh” and “Bash” is wide enough to ruin a Friday.

Now for the genuinely cursed parts. The canonical advice for safer scripts is to start with:

set -euo pipefail

This makes the script exit on errors (-e), treat unset variables as errors (-u), and fail a pipeline if any stage fails (pipefail). It is good advice, and you should use it, but it will still surprise you. The -e flag has a long list of exceptions where it does not trigger, commands in conditionals, parts of && chains, functions called in certain contexts, so a script can fail silently in exactly the place you thought you had protected. People write set -e and assume they are safe; they are merely safer.

Subshells are another trap. A pipeline runs each stage in its own subshell, so variables set inside a loop fed by a pipe vanish when the loop ends:

count=0
cat file | while read -r line; do
  count=$((count + 1))   # incremented in a subshell...
done
echo "$count"            # ...prints 0

The variable was real, it was just real in a process that no longer exists. The fix (a process substitution or a here-string) is not obvious to anyone who has not been bitten before.

Then there is the slow march to monsterhood. Every large Bash script began as a small one. You add a flag, then a config section, then a function or two, then some JSON parsing with jq, then a retry loop, and one day you are maintaining 600 lines of [[ ]] tests and case statements that nobody can safely modify. The script crossed the line where it should have been Python or Go about 400 lines ago, but there was never a single moment where rewriting felt justified. That, more than any individual footgun, is Bash’s ugliest trait: it makes the wrong tool feel like the path of least resistance right up until you are deep in the swamp.

You do not have to suffer the footguns alone. The single most valuable habit you can adopt is running ShellCheck over everything you write. It is a static analyser for shell scripts that catches the unquoted variables, the subshell traps, the misused exit codes, and most of the other classics before they bite you in production:

shellcheck deploy.sh

It will flag the rm $file from earlier, warn you when set -e will not do what you expect, and nag you into quoting. Pair it with shfmt for consistent formatting and you have eliminated a large fraction of Bash’s worst surprises with two tools. For interactive use, the -x trace flag (bash -x script.sh) prints each command as it runs with variables expanded, which turns “why did it do that” into “oh, that is why” in seconds. None of this makes Bash a safe language, but it moves a great many of its failures from runtime to your editor, which is exactly where you want them.

Bash is brilliant at exactly what it was designed for and treacherous everywhere else. For gluing commands together, automating a handful of steps, and exploring a system interactively, nothing matches its reach or its immediacy. It will be installed on machines that have never heard of your favourite language, and it will still be running pipelines long after fashionable frameworks have come and gone.

The discipline that keeps Bash pleasant is knowing when to stop. Quote your variables, run ShellCheck over everything you write, reach for set -euo pipefail while understanding its limits, and treat any script that wants real data structures or proper error handling as a signal to switch languages. Used within its lane, Bash is the indispensable glue of the internet. Pushed beyond it, it becomes the thing you grimace at in the next code review. Respect the boundary and it will serve you well; ignore it and you will write your own ugly chapter.