Getting Serious about Bash

A Rant

bashlogo 1

Bash is the fifth most-used programming language in the world, and the one that most programmers refuse to take seriously. This must change.


Nearly half the developers on Earth write Bash. Almost all of them write it badly. I mean, really badly. That is the whole argument, and everything below is commentary.

Some numbers first: According to the 2025 Stack Overflow Developer Survey, 48.7% of developers use Bash. Not “have used at some point”. They use it, actively, as part of their working lives. That puts Bash fifth in the world, behind only JavaScript, HTML/CSS, SQL and Python, and comfortably ahead of TypeScript. Fifth. A rounding error below a language everyone agrees is “serious”.

Now the other number. A 2022 study in ACM Transactions on Software Engineering and Methodology took 1.35 million Bash scripts off GitHub and ran them through static analysis. Eighty per cent carried code smells. Not obscure ones: quoting failures, word-splitting bugs, missing error handling, sloppy structure. The absolute fundamentals of the language, got wrong, at scale. I have been writing Bash professionally for over thirty years, and I can tell you the eighty per cent figure is, if anything, generous.

So here is the paradox. A language used by more developers than TypeScript, running on nearly every web server on the planet, present in every CI/CD pipeline and every container ever built, is treated with less engineering discipline than a weekend Python project written for fun. The tool that deploys the serious code is the one nobody takes seriously. Ridiculous.


Modern Bash Is Not What You Think

The objection I hear most often is that Bash is “just a shell”. A command-line convenience, not a real language. This is usually delivered with a dismissive little wave, as though the matter were settled decades ago and no further thought is required. The people who say it are picturing Bash circa 1995. They are not wrong about 1995; they are badly wrong about now.

And to be clear, I am not defending sh, nor the fossilised Bash 3.2 that macOS still ships because Apple will not touch a GPLv3 licence. I am talking about modern Bash, 4.0 and above, the language as it actually exists on every Linux system built in the last fifteen years.

Consider what has been added while nobody was looking. Bash 4.0 (2009) brought associative arrays, coprocesses and mapfile. Bash 4.3 added name references (declare -n), giving the language proper pointer-like indirection. Bash 5.0 brought epoch-second timestamps as a builtin. Bash 5.2 (2022) threw out the old ad-hoc command-substitution parser and replaced it with a recursive bison grammar. And Bash 5.3 (2025) introduced no-fork command substitution, ${ cmd; }, which captures a command’s output without spawning a subshell at all. That last one is a genuine performance primitive (and the sort of thing you would expect a language committee to boast about).

Modern Bash has typed variables via declare, lexical scoping via local, strict error handling via set -euo pipefail, regular-expression matching via =~, and signal handling via trap. It is Turing-complete, and academic researchers at POPL now treat it as a language worthy of formal analysis. The language grew up, but the people using it did not.


The Objection That Actually Deserves an Answer

“Just a shell” is a lazy jab, and easily swatted. There is a far better objection, and its most respectable form is Google’s. The Google Shell Style Guide states, more or less, that once a script grows past a hundred lines or reaches any real complexity, you should rewrite it in a structured language. Plenty of sensible engineers treat this as settled law.

It is worth taking seriously, and then it is worth dismantling, because the reasoning is backwards. The hundred-line rule is not an observation about Bash. It is a confession about the Bash its authors had seen. They watched undisciplined shell scripts collapse somewhere north of a hundred lines (no error handling, no functions, global variables everywhere, quoting held together with hope) and concluded that the collapse was a property of the language. It was not. It was a property of the scripts. A hundred-line ceiling on Bash is just a speed limit set by the worst driver on the road.

I have production Bash well past a thousand lines that has run without incident for years, because it was written the way you would write anything else: functions, strict mode, local variables, input validation, tests. The ceiling moves the moment the discipline arrives.

Whilst I am on the attack, let me concede the genuine ground, because a rant that admits nothing persuades nobody. Bash has limits, and they are worth knowing precisely, if only so you stop attacking the fake ones. Its data structures stop at scalars, indexed arrays and associative arrays, and crucially they do not nest: no arrays of arrays, no records, no structs. Pass anything structured between functions and you are serialising strings and praying. It has no namespaces; every function lives in one global pool, and name collisions are your problem to manage. Native arithmetic is integer-only, so floating point means shelling out to bc or awk. And set -e, the thing keeping your script honest, has enough documented edge cases to fill an afternoon. These are the real constraints. Respect them, and you will know when Bash is the wrong tool.

But notice what is not on that list. “Untyped.” The critics love to call Bash untyped, and it is the tell that they have never actually read the manual. declare -i gives you a real integer. Arithmetic evaluation with (( )) treats your flags as the booleans they are, and it is measurably faster than the string comparisons most people reach for. while ((1)) beats while true by a whopping margin over a thousand iterations, because one is pure arithmetic and the other is a command lookup. Integer and boolean typing is among the most underused optimisations in the entire language. The people dismissing Bash as a typeless toy are not describing Bash; they are describing the extent of their own reading.


Where Bash Actually Wins

The case for Bash is practical, and the numbers are specific. Bash starts in roughly 2.8 milliseconds. Python takes around 11, four times longer, and often much worse once imports land. A minimal Bash container image runs about 12 megabytes; Python on Alpine runs about 60. When you are building minimal containers, deploying to constrained hardware, or spawning thousands of short-lived processes, none of that is academic.

More important than any of it: Bash has zero dependencies. No runtime to install, no package manager to configure, no virtual environment to activate, no lockfile, no supply chain to audit for the language itself. Compare a “simple” Python deployment script that quietly drags in pip, virtualenv, a pinned requirements file, and a prayer that none of it collides with the system Python half your other tools depend on. Anyone who has lost an afternoon to a ModuleNotFoundError in a pipeline that worked yesterday knows the shape of this particular hell. Bash does not have the problem. It does not have dependencies. It is the dependency.

In air-gapped networks, in regulated shops, in minimal containers where every megabyte is counted, Bash is the language that is already present when nothing else is permitted to be. On the overwhelming majority of the world’s web servers, on cloud virtual machines, in every GitHub Actions runner and every GitLab executor and every Jenkins agent, it is already there, waiting.

And people build serious things with it. nvm, the Node Version Manager, has over 92,000 GitHub stars and is written entirely in Bash. acme.sh, the pure-shell ACME client issuing TLS certificates across the internet, has more than 46,000. pyenv, pi-hole, dokku: all Bash, all critical infrastructure, all used by millions daily. These are not toys.

The “right tool for the right job” line, so often deployed against Bash, actually favours it. If you are orchestrating CLI commands, wrangling files, composing pipelines, or scripting a container entrypoint, Bash is the native language of the problem. Wrapping aws, kubectl and terraform inside Python’s subprocess.run() buys you indirection and a dependency tree and nothing else. The command line was built for the shell. Use it, ffs!


Bad Bash in the Wild

OK, enough theory. If eighty per cent of Bash is bad, where is it? It is on the machine in front of you, shipped by the largest names in software, running as root.

I went looking on a stock Ubuntu system. Start with the package maintainer scripts, the postinst files that run automatically, as root, every time you install or upgrade software. I ran ShellCheck (the standard static analyser for shell) across four hundred of them. One hundred and six had findings. Five hundred and sixty-nine findings in total. The worst were not obscure packages: GRUB, the bootloader that decides whether your machine starts, with thirty-nine; CUPS with thirty-five; MySQL with thirty-one; Microsoft Edge with twenty-seven; Google Chrome with eighteen.

Pick one line, from MySQL’s maintainer script, which handles the database’s own administrative password:

pass="`sed -n 's/^[     ]*password *= *// p' $dc | head -n 1`"

I mean, wtaf?! Legacy backtick substitution (deprecated for two decades), an unquoted $dc that will break the moment a path contains a space, and this is the code extracting a password on a production database, as root. Nobody ran a linter over it. Nobody had to; it is “just a script”.

Now, a specimen I should frame and hang on a wall: dovecot-sysreport ships with Dovecot, the mail server running a serious fraction of the world’s IMAP. It is not some deprecated corner: it lives in Ubuntu’s supported main repository, it was security-patched this release cycle, and it is the tool Dovecot asks users to run and attach to bug reports. Its output goes straight to Dovecot’s own developers. Seventy ShellCheck findings in two hundred and sixteen lines. And reading the whole thing, rather than the linter summary, turns up three bugs that no static analyser would phrase for you.

The first is a function that hunts through Dovecot’s config for passwords and redacts them before the report is sent:

unwrap_and_hide_pass () {
  files=`grep ... $1 ...`              # global, not local
  for cf in $files; do                 # cf global, not local
    if [ -r "$cf" ]; then
        unwrap_and_hide_pass $cf       # recurses, clobbering cf
        mkdir -p $SRTEMP/conf"$(dirname "$cf")" # uses cf AFTER the call

The function recurses on nested config includes, but neither files nor the loop variable cf is declared local. So the recursive call overwrites its caller’s cf, and every use of cf after that call (the directory creation, the file that gets read and redacted) operates on the wrong path. A password-hiding routine that, on any configuration with nested includes, silently mishandles the very files whose passwords it exists to hide!

The second: the cleanup routine that deletes the temporary working directory is installed with trap on the second-to-last line of the script, after every temporary file has already been created and used. Any failure in the two hundred lines before it (a missing binary, an unreadable core file, a full disk) leaves the temp directory orphaned on disk. A bug-reporting tool that litters the moment it hits a bug.

The third is a parse ambiguity so fine that ShellCheck flags it as a hard error, not a warning: $((echo ...) at the start of a line, where the author meant a command substitution wrapping a subshell but wrote something Bash is entitled to read as arithmetic. It works today by luck of what follows it.

If you want the failure demonstrated rather than described, zipgrep obliges. It ships with Info-ZIP, it has been on essentially every Unix machine for thirty years, and it searches inside Zip archives. Watch:

$ unzip -Z1 glob.zip          # the archive holds ONE member, named:
*.txt
$ zipgrep secret glob.zip     # and "secret" is genuinely inside it
caution: filename not matched:  my notes.txt
caution: filename not matched:  plain.txt
$ echo $?
1

The pattern is in the archive. zipgrep reports no match. The bug is a single unquoted variable in a for loop: the member name *.txt underwent filename expansion against the current directory, so the script went looking for files that were never in the archive and never searched the one that was. Same command, different working directory, different answer. To its modest credit the author had defended against spaces in filenames; the glob slipped through anyway. Folk knowledge does not scale. Discipline does.

I rewrote zipgrep to the Bash Coding Standard, in a comparable number of lines, and it is ShellCheck-clean. Same directory, same archive:

$ zipgrep-bcs secret glob.zip     # the disciplined version
*.txt:secret pattern

Correct. The fix was not cleverness. It was quoting a variable and using an array where the original spliced a string. The discipline is the whole difference.

And then there is the specimen that ought to end the argument. rkhunter is a rootkit and exploit scanner. Its entire purpose is to detect that your machine has been compromised, and it runs as root to do it. Twenty-one thousand lines of Bash, three thousand and twenty-five ShellCheck findings, of which two thousand two hundred and eighty-six are the same two schoolboy errors the ACM study named: unquoted variables and legacy backticks. Some of that is age, and honesty compels me to say the tool has not been seriously maintained upstream since around 2018. But age does not excuse this, from the routine that hunts for one particular rootkit:

if `cd ${ROOTKIT_PHALANX2_DIRNAME} >/dev/null 2>&1`; then

That executes the output of cd as a command. The redirect inside the backticks swallows everything, so the condition tests an empty string, and the check works only by an obscure accident of how the shell assigns an exit status to an empty command. A security scanner, running as root, defending your machine against attackers, built on the exact command-injection-shaped idioms that let attackers in. Jesus wept…

Now the counterpoint, because it is the most important paragraph here. I ran the same linter over the famous install scripts you are told to curl into your shell. Docker’s get.docker.com: zero findings. Homebrew’s installer: zero. RVM’s: zero. That is the whole thesis in one table. Disciplined Bash at scale is not a fantasy; Docker and Homebrew do it every day. When MySQL, Google, Microsoft and a root-run security scanner ship the sloppy version, the language is not what failed them. Their damn attitude to the language is.


The Shebang Nobody Tests

A quick detour, because it is the same disease one layer down. Look again at Microsoft Edge’s maintainer script, and at Debian’s own select-editor, the utility every new Ubuntu user runs to pick a text editor. Both declare #!/bin/sh. Both then use local, which is not part of POSIX sh at all.

They run anyway, and only by accident. On Debian and Ubuntu, /bin/sh is a symlink to dash, and dash happens to implement local even though the standard it claims to obey does not require it. Change that symlink to a stricter shell (busybox ash in an Alpine container, say) and the script dies. The #!/bin/sh line is a promise of portability that nobody ever tested. It is the identical failure to the one this whole rant is about: declare one contract, write to a different one, and stay upright purely because of which binary the symlink points at today. ShellCheck will check the promise for you in one command (shellcheck -s sh). Microsoft, Debian and the rest do not bother. Two symlink targets, two outcomes, zero discipline.


The Real Problem

So if Bash is this capable and this ubiquitous, why is so much of the code this poor?

Because Bash is the only mainstream language most developers learn entirely by copying from Stack Overflow (and guess where our LLMs trained). There is no university course in shell scripting. There is no onboarding module, no equivalent of Python’s PEP 8 or Go’s gofmt that a new hire meets in their first week. Nobody sits a junior engineer down and explains how to write a proper script. They hand over a broken deployment pipeline and wish them luck.

And when programmers go looking for guidance, what they find is worse than silence. The top-ranked Bash tutorials on the open web (freeCodeCamp, W3Schools, GeeksforGeeks, TutorialsPoint) uniformly teach unquoted variables, single-bracket conditionals, and scripts with no error handling whatsoever. Not one of them so much as mentions set -euo pipefail. The venerable TLDP Advanced Bash-Scripting Guide, which shaped a generation, still presents backtick substitution as primary syntax and has not been updated since 2014. These are not fringe pages. They are the front page of Google, and they are teaching the language broken.

The result is cargo-cult scripting: patterns copied without comprehension, errors bred through generations of tutorial sites, and a collective shrug when something breaks, because “it’s just a script”.

That shrug has a price. On 30 October 2023, Cloudflare suffered a 37-minute outage of Workers KV, the key-value store underpinning a long list of their products. The root cause, in their own words, was that “a script that had been deployed to the staging account was incorrectly referenced instead of the pre-production version on the production account”. Not a subtle distributed-systems failure. A script that pointed at the wrong environment, reviewed with nothing like the rigour a Python module would have earned.

“It’s just a script” may be the most expensive sentence in DevOps. Every pipeline, every deployment workflow, every container entrypoint, every cron job is “just a script” right up until it takes production down. The same engineer who would never push an untested Python function will happily commit a two-hundred-line deployment script with no error handling, no input validation and no tests, and would sooner resign than skip code review on a Go service. But the Bash that actually ships that Go service? A glance, if that. The double standard is indefensible.


Getting Serious

The tools already exist, and they are free. ShellCheck, with over 37,000 GitHub stars, catches quoting errors, undefined variables and the common traps before they reach production. Strict mode (set -euo pipefail) converts Bash from a language that shrugs off errors into one that fails fast and loud. bats-core brings structured testing to shell. shfmt enforces consistent style. Every one of the disasters above would have been caught, in seconds, by a tool that was sitting right there.

But ShellCheck is just the floor, not the ceiling. It will flag the unquoted $dc in MySQL’s password line. But it will not tell you that dovecot-sysreport’s recursive redactor corrupts its own targets, or that its cleanup trap is installed too late to ever fire, because those are not lexical mistakes; they are failures of structure, and structure is exactly what a coding standard is for. This is why we wrote the Bash Coding Standard and built an LLM-backed checker to enforce it: to catch the class of bug that lives above the linter’s reach. When I ran that checker over dovecot-sysreport, it named all three of those bugs, in a security-sensitive tool, in Ubuntu’s supported repository, shipping today. Passing ShellCheck is absolutely necessary, but it is not sufficient. Get serious about both shellcheck and bcscheck.

The rule is not complicated, and it is this. If you would not ship an unreviewed Python function, do not ship an unreviewed Bash script. If you would not write Go without go vet, do not write Bash without ShellCheck. If you would not deploy a Java service without tests, do not deploy the deployment script without tests. The script that deploys the service is not beneath the standard you hold the service to. It is the thing that decides whether the service runs at all.

Bash is not going away. It is the fifth most-used language in the world, it runs on nearly every server you own, and it is the connective tissue of your entire infrastructure. It has earned engineering discipline. The tools to enforce this discipline are already installed on the machine you are reading this on.

Gary Dean is the principal of Okusi Associates and co-founder of YaTTI (Indonesian Open Technology Foundation), and co-creator of the Bash Coding Standard.