Bash, the good, bad and ugly
The glue holding the internet together

Contents
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 — we have given Python and Go the same three-act treatment — here is Bash in the dock.
A bit of background
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.
The Good
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:
| |
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 Bad
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.
| |
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.
The Ugly
Now for the genuinely cursed parts. The canonical advice for safer scripts is to start with:
| |
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:
| |
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.
A script that won’t betray you
Theory is cheap, so here is the skeleton I actually start every non-trivial script from. It bundles the safe-mode flags, a trap that fires on any error with the failing line number, and quoting throughout. It is not long, and it removes a startling proportion of the failure modes above:
| |
Three things in there are doing real work. The ${1:?...} syntax fails loudly with a usage message if the first argument is missing, instead of carrying on with an empty variable. The find ... -print0 paired with read -r -d '' is the only robust way to iterate filenames — it uses the null byte as a separator, so a filename with a space, a newline, or a leading dash cannot break the loop or get swallowed by word-splitting. And feeding the loop with < <(...) — process substitution — keeps the array in the current shell rather than a subshell, sidestepping the vanishing-variable trap from the previous section. None of this is intuitive; all of it is muscle memory once a footgun has cost you an afternoon.
When to bail to a real language
The single most useful Bash skill is recognising the moment to stop writing Bash. The signals are concrete. You reach for an associative array to model anything with structure — bail. You are parsing JSON with more than a single jq filter, or worse, with grep and cut — bail. You need real error handling with cleanup and rollback across several steps — bail. You are writing functions that return data by echoing strings and capturing them, fighting the fact that Bash functions can only really return an exit code — bail. You want anyone else to maintain this — strongly consider bailing.
The destination is usually Python for glue-with-logic or Go for something you want to ship as a single binary, and both wear their own trade-offs; we covered Python’s in this same series. The mistake is treating the rewrite as a defeat. It is not. Bash got you to the point where the requirements were clear, which is genuinely the hard part. Porting a working 200-line script to Python once you understand exactly what it must do is an afternoon’s work and pays for itself the first time someone — possibly you, in six months — has to change it.
Tools that make Bash bearable
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:
| |
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.
The Verdict
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.




